Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4da617d59 | ||
|
|
729e699eab |
No files matched your search
+61
-84
@@ -2,73 +2,6 @@
|
||||
|
||||
Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fixed legacy-to-SDK startup migration to adopt native Coding Experience and
|
||||
Working Fact IDs instead of importing them as Chat layers. Raw migration now
|
||||
rebuilds bounded user/assistant turns from the latest three days, excludes
|
||||
tools, and enforces the same rolling retention after cutover.
|
||||
- Paused host capture writes with a live Desktop migration fence; Cursor
|
||||
History catch-up resumes from its existing checkpoint after cutover.
|
||||
- Removed MemHub's private `hy_memory_bridge` implementation. MemHub now calls
|
||||
public `HyMemoryClient` methods at runtime; prompt/reconcile/response
|
||||
monkey-patches and private VDB/Coding runtime access were deleted. Kuzu Graph
|
||||
projection/admin and stopped-writer App migration are the documented product
|
||||
exceptions.
|
||||
- Added a MemHub-managed native Hermes MemoryProvider with automatic prefetch,
|
||||
persistent per-session memory-ID deduplication, authorized Broker reads, and
|
||||
recall-context stripping to prevent memory recursion.
|
||||
- Added an extensible MemHub interface-localization layer with Simplified
|
||||
Chinese, English, and Spanish catalogs shared by Web and Electron-native
|
||||
surfaces. Interface language remains independent from memory content and
|
||||
model processing language.
|
||||
- Fixed governed-memory revisions and repeated evidence links failing when the
|
||||
visibility dirty-marker trigger encounters the same memory twice. Startup
|
||||
now replaces the affected legacy trigger without changing schema versions
|
||||
or governed and visibility data.
|
||||
- Kept post-write authority verification failures available for read-only
|
||||
reconciliation while preventing automatic or generic confirmed retries from
|
||||
invoking the model again. Automatic reconciliation skips those sticky rows
|
||||
before applying its batch limit, and linked durable jobs exclusively own
|
||||
retry scheduling for their memory tasks.
|
||||
- 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.5 — 2026-10-22
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.5`](https://pypi.org/project/hy-memory/2.0.5/).
|
||||
- Extended `get()` across Chat, Coding Experience, Working Fact, and raw IDs;
|
||||
the returned `layer`, `kind`, and `variant` identify the actual record.
|
||||
- Added explicit raw retrieval through the existing `search()` and
|
||||
`list_memories()` contracts, including user, agent, session, and source
|
||||
filters.
|
||||
- Added public embedding-service injection and corrected extraction language
|
||||
detection for structured OpenAI-style messages.
|
||||
- Added lifecycle status filters, durable idempotent write outcomes, and
|
||||
quiesced local data-plane snapshot/restore.
|
||||
- Kept legacy migration mechanics out of the public Client API; separate
|
||||
migration-only raw/import methods from development previews were removed.
|
||||
- Physical delete now reports partial VDB/Graph cleanup as failure.
|
||||
- Coding search no longer injects the version-evolution diff block by default;
|
||||
opt in with `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true`.
|
||||
- Serialized SDK SQLite cache/history connection lifecycle and tightened
|
||||
Coding reconciliation to its agent/session/workspace authority.
|
||||
- Clarified persistent caller context through `meta`, while `metadata` remains
|
||||
legacy processing configuration.
|
||||
|
||||
## Hy Memory 2.0.5.dev4 — 2026-10-21
|
||||
|
||||
- Development preview used for SDK-authority migration validation; not the
|
||||
final `2.0.5` release.
|
||||
- Published as
|
||||
[`hy-memory==2.0.5.dev4`](https://pypi.org/project/hy-memory/2.0.5.dev4/).
|
||||
|
||||
## Hy Memory 2.0.5.dev3 — 2026-10-21
|
||||
|
||||
- Development preview; not the final `2.0.5` release.
|
||||
@@ -95,12 +28,6 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
APIs, offline local data-plane snapshot/restore, and fail-closed physical
|
||||
delete receipts.
|
||||
|
||||
## Hy Memory 2.0.4 — 2026-10-17
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.4`](https://pypi.org/project/hy-memory/2.0.4/) as a
|
||||
maintenance release.
|
||||
|
||||
## Hy Memory 2.0.3 — 2026-10-17
|
||||
|
||||
- Added an optional runtime HTTP transport factory so embedded products can
|
||||
@@ -111,6 +38,66 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
unchanged memory extraction, reconciliation, storage, and Coding/Working
|
||||
behavior.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Removed MemHub's private `hy_memory_bridge` implementation. MemHub now calls
|
||||
public `HyMemoryClient` methods at runtime; prompt/reconcile/response
|
||||
monkey-patches and private VDB/Coding runtime access were deleted. Kuzu Graph
|
||||
projection/admin and stopped-writer App migration are the documented product
|
||||
exceptions.
|
||||
- Extended public SDK `get()` to resolve Chat, Coding Experience, and Working
|
||||
Fact IDs, including raw records, owner/session fields, and explicit ambiguity
|
||||
handling; added constructor-time public embedding-service injection.
|
||||
- Added a MemHub-managed native Hermes MemoryProvider with automatic prefetch,
|
||||
persistent per-session memory-ID deduplication, authorized Broker reads, and
|
||||
recall-context stripping to prevent memory recursion.
|
||||
- Added an extensible MemHub interface-localization layer with Simplified
|
||||
Chinese, English, and Spanish catalogs shared by Web and Electron-native
|
||||
surfaces. Interface language remains independent from memory content and
|
||||
model processing language.
|
||||
- `search(..., layers=["l1_raw"])` now explicitly searches SDK raw records in
|
||||
pro/ultra mode and keeps existing user/agent/session/source filters; default
|
||||
search continues to hide raw.
|
||||
- Added SDK-authoritative lifecycle listing with explicit status filters,
|
||||
idempotent durable write outcomes, and quiesced local data-plane
|
||||
snapshot/restore contracts for the MemHub authority migration. Exact legacy
|
||||
backfill is now an App-owned privileged offline operation rather than a
|
||||
public `HyMemoryClient` API. Backfill runs against a complete staging copy;
|
||||
cutover atomically swaps SDK roots and restores the previous root on failure.
|
||||
- Physical delete now reports incomplete VDB/Graph cleanup as a partial failure
|
||||
instead of claiming success after only one store was changed.
|
||||
- Coding search no longer injects the version-evolution diff block
|
||||
(`chain_block`) by default. Enable with
|
||||
`MEMORY_CODING_CHAIN_BLOCK_ENABLED=true` or
|
||||
`CodingConfig.chain_block_enabled=True` (write-side observe unchanged).
|
||||
- Fixed governed-memory revisions and repeated evidence links failing when the
|
||||
visibility dirty-marker trigger encounters the same memory twice. Startup
|
||||
now replaces the affected legacy trigger without changing schema versions
|
||||
or governed and visibility data.
|
||||
- Serialized each SDK SQLite store's shared connection lifecycle so concurrent
|
||||
history/cache operations cannot race with `close()` and crash the process.
|
||||
- Scoped Coding reconciliation targets to the current HY authority,
|
||||
conversation, and workspace, preventing near-duplicate sessions from
|
||||
mutating an experience or working fact owned by another evidence context.
|
||||
- Kept post-write authority verification failures available for read-only
|
||||
reconciliation while preventing automatic or generic confirmed retries from
|
||||
invoking the model again. Automatic reconciliation skips those sticky rows
|
||||
before applying its batch limit, and linked durable jobs exclusively own
|
||||
retry scheduling for their memory tasks.
|
||||
- Clarified that `add(metadata=...)` carries legacy SDK processing options,
|
||||
while persistent caller business context belongs in `meta`.
|
||||
- Unified exact reads and lists across memory layers: `get(memory_id)` returns
|
||||
the matched layer, while `list_memories(layers=["l1_raw"])` explicitly lists
|
||||
raw records. Separate raw get/list methods and the `include_raw` switch were
|
||||
removed.
|
||||
- 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
|
||||
@@ -125,17 +112,7 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
Because PyPI release metadata is immutable, the next SDK patch release must
|
||||
carry the updated license metadata; `2.0.1` retains its published metadata.
|
||||
|
||||
## MemHub 1.0.5 — Unreleased
|
||||
|
||||
- Updated the source version to `1.0.5` (build 14).
|
||||
- Pinned the public SDK to the stable `hy-memory==2.0.5` release.
|
||||
|
||||
## MemHub 1.0.4 — 2026-10-18
|
||||
|
||||
- Updated the source version to `1.0.4` (build 13).
|
||||
- Published the `v1.0.4` source and release metadata.
|
||||
|
||||
## MemHub 1.0.3 — 2026-10-17
|
||||
## MemHub 1.0.3 — Unreleased
|
||||
|
||||
- Updated the source version to `1.0.3` (build 7).
|
||||
- Pinned the public SDK to `hy-memory==2.0.3`.
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
接口先补 SDK。Runtime writers 停止后的 App 启动迁移可直接操作底层存储,
|
||||
但不得把迁移专用方法加入公共 Client。
|
||||
3. MemHub **不存 memory 实体**;**可以存 `memory_id`** 做组织/回执。原文进入 **SDK raw**。
|
||||
4. SDK raw 只保留滚动三天;一条 raw 对应一个完整 turn,只含真人 user 与
|
||||
assistant,tool 不持久化。提取后的高层 memory 不受该窗口影响。
|
||||
|
||||
生产入口通过共享 Runtime 租约取得公开 `HyMemoryClient`;MemHub 只在调用前执行
|
||||
模型配置、密钥和 harness/model 门禁,不维护第二套 memory 正文权威。
|
||||
|
||||
+5
-5
@@ -18,13 +18,13 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
||||
|
||||
## 发行与平台合同
|
||||
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.5`。MemHub 源码版本为 `1.0.5`
|
||||
(build 14),GitHub 仓库为
|
||||
源码当前固定使用公开 PyPI 开发预览 `hy-memory==2.0.5.dev3`。MemHub 源码版本为 `1.0.4`
|
||||
(build 13),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
|
||||
`v1.0.4` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
|
||||
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
|
||||
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
|
||||
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
|
||||
@@ -45,7 +45,7 @@ Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命
|
||||
|
||||
## 仓库结构
|
||||
|
||||
- `apps/core`:Python / FastAPI Kernel,负责授权、模型、队列、SDK Runtime 边界与 API。
|
||||
- `apps/core`:Python / FastAPI Kernel,负责授权、模型、队列、SDK bridge 与 API。
|
||||
- `apps/cli`:安装、控制和诊断共享 Runtime 的 `one-memory` CLI。
|
||||
- `apps/web`:React / TypeScript 管理界面。
|
||||
- `apps/desktop`:Electron 桌面壳与图形安装入口。
|
||||
@@ -117,7 +117,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
||||
|
||||
## Hy Memory
|
||||
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.5`。SDK 配置、权限边界及 search
|
||||
SDK 依赖固定为公开 PyPI 开发预览 `hy-memory==2.0.5.dev3`。SDK 配置、权限边界及 search
|
||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "one-memory",
|
||||
"documentNamespace": "https://one-memory.local/spdx/2026-10-22T13:18:16Z",
|
||||
"documentNamespace": "https://one-memory.local/spdx/2026-10-22T08:09:31Z",
|
||||
"creationInfo": {
|
||||
"created": "2026-10-22T13:18:16Z",
|
||||
"created": "2026-10-22T08:09:31Z",
|
||||
"creators": [
|
||||
"Tool: one-memory-generate-sbom"
|
||||
]
|
||||
@@ -2352,7 +2352,7 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||
"name": "hy-memory",
|
||||
"versionInfo": "2.0.5",
|
||||
"versionInfo": "2.0.5.dev3",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "Apache-2.0",
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
MemHub itself is licensed under Apache-2.0 (see `LICENSE`).
|
||||
It bundles the runtime dependencies listed below.
|
||||
Generated from the installed dependency tree on 2026-10-22T13:18:16Z
|
||||
Generated from the installed dependency tree on 2026-10-22T08:09:31Z
|
||||
by `python3 scripts/generate_sbom.py`. Do not edit by hand.
|
||||
|
||||
Per-package license texts are not reproduced here yet; the license
|
||||
column is filled in as part of release review, and the SBOM records
|
||||
`NOASSERTION` until then rather than guessing.
|
||||
|
||||
## hy-memory 2.0.5
|
||||
## hy-memory 2.0.5.dev3
|
||||
|
||||
License: Apache-2.0
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/cli",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import { DiagnosticLogger } from "@one-memory/runtime-control";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -30,27 +29,6 @@ async function fixture() {
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
function readZipEntry(archive: Buffer, expected: string): string {
|
||||
let offset = 0;
|
||||
while (archive.readUInt32LE(offset) === 0x04034b50) {
|
||||
const method = archive.readUInt16LE(offset + 8);
|
||||
const compressedSize = archive.readUInt32LE(offset + 18);
|
||||
const nameLength = archive.readUInt16LE(offset + 26);
|
||||
const extraLength = archive.readUInt16LE(offset + 28);
|
||||
const nameStart = offset + 30;
|
||||
const dataStart = nameStart + nameLength + extraLength;
|
||||
const name = archive.subarray(nameStart, nameStart + nameLength).toString();
|
||||
const compressed = archive.subarray(dataStart, dataStart + compressedSize);
|
||||
if (name === expected) {
|
||||
if (method === 0) return compressed.toString();
|
||||
if (method === 8) return inflateRawSync(compressed).toString();
|
||||
throw new Error("unsupported zip compression");
|
||||
}
|
||||
offset = dataStart + compressedSize;
|
||||
}
|
||||
throw new Error(`missing ${expected}`);
|
||||
}
|
||||
|
||||
it("prints matching diagnostic events as NDJSON without starting Runtime", async () => {
|
||||
const dataDir = await fixture();
|
||||
let stdout = "";
|
||||
@@ -96,25 +74,6 @@ it("exports one support zip to the requested downloads directory", async () => {
|
||||
});
|
||||
expect(receipt.path).toMatch(/MemHub-日志-3800-\d{8}-\d{4}\.zip$/);
|
||||
expect(await readdir(downloads)).toEqual([path.basename(receipt.path)]);
|
||||
const archive = await readFile(receipt.path);
|
||||
expect(JSON.parse(readZipEntry(archive, "summary.json")).commit).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed for an explicitly supplied invalid commit", async () => {
|
||||
const dataDir = await fixture();
|
||||
const downloads = await mkdtemp(path.join(os.tmpdir(), "om-cli-downloads-"));
|
||||
roots.push(downloads);
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
await expect(
|
||||
run(["logs", "export", "--json"], {
|
||||
ONE_MEMORY_DATA_DIR: dataDir,
|
||||
ONE_MEMORY_EXPORT_DIR: downloads,
|
||||
ONE_MEMORY_BUILD_NUMBER: "3800",
|
||||
ONE_MEMORY_COMMIT: "unknown",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_commit_invalid");
|
||||
expect(await readdir(downloads)).toEqual([]);
|
||||
});
|
||||
|
||||
it("never emits a secret from a malformed manually written event", async () => {
|
||||
|
||||
@@ -1048,21 +1048,15 @@ async function runCommand(
|
||||
entry: "cli",
|
||||
}));
|
||||
const target = path.join(downloads, diagnosticZipFilename(build));
|
||||
const environmentCommit = environment.ONE_MEMORY_COMMIT?.trim();
|
||||
const embeddedCommit =
|
||||
typeof __ONE_MEMORY_COMMIT__ === "string"
|
||||
? __ONE_MEMORY_COMMIT__.trim()
|
||||
: "";
|
||||
const commit =
|
||||
environmentCommit ||
|
||||
(embeddedCommit && embeddedCommit !== "unknown"
|
||||
? embeddedCommit
|
||||
: undefined);
|
||||
const receipt = await exportDiagnosticZip({
|
||||
dataDir: config.dataDir,
|
||||
target,
|
||||
productVersion: build,
|
||||
...(commit === undefined ? {} : { commit }),
|
||||
commit:
|
||||
environment.ONE_MEMORY_COMMIT?.trim() ||
|
||||
(typeof __ONE_MEMORY_COMMIT__ === "string"
|
||||
? __ONE_MEMORY_COMMIT__
|
||||
: "unknown"),
|
||||
coreSha256: await sha256File(config.coreCommand).catch(() => undefined),
|
||||
home: os.homedir(),
|
||||
crashReportsDir: path.join(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DiagnosticLogger } from "@one-memory/runtime-control";
|
||||
@@ -41,15 +41,11 @@ it("redacts unsafe details and absolute stack frames from the read surface", ()
|
||||
session_id: "11111111-1111-4111-8111-111111111111",
|
||||
details: {
|
||||
count: 1,
|
||||
reason: "/Users/me/private-query.txt",
|
||||
status: "sk-live-secret-token",
|
||||
source_id: "credential.private",
|
||||
prompt: "secret-token-123",
|
||||
authorization: "Bearer secret-token-123",
|
||||
} as unknown as Record<string, string | number | boolean | null>,
|
||||
safe_stack: [
|
||||
"packages/runtime-control/src/example.ts:42:7",
|
||||
"packages/runtime/sk-live-secret-token.ts:1:1",
|
||||
"/Users/me/private.py:1",
|
||||
"C:\\Users\\me\\private.py:1",
|
||||
],
|
||||
@@ -60,8 +56,6 @@ it("redacts unsafe details and absolute stack frames from the read surface", ()
|
||||
"packages/runtime-control/src/example.ts:42:7",
|
||||
]);
|
||||
expect(JSON.stringify(redacted)).not.toContain("secret-token-123");
|
||||
expect(JSON.stringify(redacted)).not.toContain("sk-live-secret-token");
|
||||
expect(JSON.stringify(redacted)).not.toContain("credential.private");
|
||||
expect(JSON.stringify(redacted)).not.toContain("/Users/me");
|
||||
expect(JSON.stringify(redacted)).not.toContain("C:\\\\Users");
|
||||
});
|
||||
@@ -138,27 +132,3 @@ it("follows a newly appended event without stopping Runtime", async () => {
|
||||
});
|
||||
expect(seen).toEqual(["service.ready", "core.failed"]);
|
||||
});
|
||||
|
||||
it("fails follow mode when the diagnostic directory is unsafe", async () => {
|
||||
const dataDir = await mkdtemp(
|
||||
path.join(os.tmpdir(), "om-cli-follow-unsafe-"),
|
||||
);
|
||||
const external = await mkdtemp(
|
||||
path.join(os.tmpdir(), "om-cli-follow-external-"),
|
||||
);
|
||||
roots.push(dataDir, external);
|
||||
await symlink(external, path.join(dataDir, "logs"));
|
||||
const query = parseLogQuery(["--follow"]);
|
||||
expect(query).not.toBeNull();
|
||||
if (!query) throw new Error("query_missing");
|
||||
await expect(
|
||||
followLogs({
|
||||
dataDir,
|
||||
query,
|
||||
signal: new AbortController().signal,
|
||||
maxEvents: 1,
|
||||
emit: () => undefined,
|
||||
wait: async () => undefined,
|
||||
}),
|
||||
).rejects.toThrow("log_directory_unreadable");
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
|
||||
import {
|
||||
diagnosticLogFiles,
|
||||
projectDiagnosticEventForSafeOutput,
|
||||
readDiagnosticEvents,
|
||||
} from "@one-memory/runtime-control";
|
||||
|
||||
@@ -25,6 +24,54 @@ export interface LogQuery {
|
||||
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
||||
const NAME = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+){0,15}$/;
|
||||
const SAFE_DETAIL_KEYS = new Set([
|
||||
"action",
|
||||
"attempt",
|
||||
"count",
|
||||
"method",
|
||||
"mode",
|
||||
"phase",
|
||||
"platform",
|
||||
"reason",
|
||||
"result_count",
|
||||
"source_id",
|
||||
"spawned",
|
||||
"status",
|
||||
"version",
|
||||
"parent_session_id",
|
||||
"invalid_lines",
|
||||
"truncated",
|
||||
"runtime_state",
|
||||
"core_state",
|
||||
"broker_state",
|
||||
"instance_id",
|
||||
"open_file_count",
|
||||
"memory_bytes",
|
||||
"thread_count",
|
||||
"database_error_count",
|
||||
"file_count",
|
||||
"sqlite_file_count",
|
||||
"byte_file_count",
|
||||
"total_bytes",
|
||||
"copied_bytes",
|
||||
"bytes_per_second",
|
||||
"stored_count",
|
||||
"revised_count",
|
||||
"skipped_count",
|
||||
"error_count",
|
||||
"pending_count",
|
||||
"prepared_count",
|
||||
"queue_depth",
|
||||
"in_flight_count",
|
||||
"call_count",
|
||||
"created_count",
|
||||
"updated_count",
|
||||
"superseded_count",
|
||||
"deleted_count",
|
||||
"operation_kind",
|
||||
"file_kind",
|
||||
]);
|
||||
|
||||
export function parseLogQuery(arguments_: readonly string[]): LogQuery | null {
|
||||
if (arguments_.filter((item) => item === "--json").length > 1) return null;
|
||||
const values: Partial<LogQuery> = { errorsOnly: false, follow: false };
|
||||
@@ -190,13 +237,63 @@ export function renderLogText(
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/** Defence in depth for the CLI read surface. Local writers accept arbitrary
|
||||
* bounded scalar keys, so every outward read surface must reuse the same
|
||||
* field-and-value projection as the support-bundle exporter. */
|
||||
/** Defence in depth for the CLI read surface. `DiagnosticLogger` already
|
||||
* allow-lists keys, but a future writer, a manually edited log, or a malformed
|
||||
* line must not turn `memhub logs` into an exfiltration path. */
|
||||
export function redactDiagnosticEvent(
|
||||
event: DiagnosticEventV1,
|
||||
): DiagnosticEventV1 {
|
||||
return projectDiagnosticEventForSafeOutput(event);
|
||||
const redacted: DiagnosticEventV1 = {
|
||||
schema_version: 1,
|
||||
timestamp: event.timestamp,
|
||||
level: event.level,
|
||||
service: event.service,
|
||||
component: event.component,
|
||||
event: event.event,
|
||||
outcome: event.outcome,
|
||||
session_id: event.session_id,
|
||||
...(event.trace_id === undefined ? {} : { trace_id: event.trace_id }),
|
||||
...(event.incident_id === undefined
|
||||
? {}
|
||||
: { incident_id: event.incident_id }),
|
||||
...(event.instance_id === undefined
|
||||
? {}
|
||||
: { instance_id: event.instance_id }),
|
||||
...(event.operation_id === undefined
|
||||
? {}
|
||||
: { operation_id: event.operation_id }),
|
||||
...(event.request_id === undefined ? {} : { request_id: event.request_id }),
|
||||
...(event.job_id === undefined ? {} : { job_id: event.job_id }),
|
||||
...(event.task_id === undefined ? {} : { task_id: event.task_id }),
|
||||
...(event.duration_ms === undefined
|
||||
? {}
|
||||
: { duration_ms: event.duration_ms }),
|
||||
...(event.state_before === undefined
|
||||
? {}
|
||||
: { state_before: event.state_before }),
|
||||
...(event.state_after === undefined
|
||||
? {}
|
||||
: { state_after: event.state_after }),
|
||||
...(event.error_code === undefined ? {} : { error_code: event.error_code }),
|
||||
...(event.error_type === undefined ? {} : { error_type: event.error_type }),
|
||||
...(event.safe_stack === undefined
|
||||
? {}
|
||||
: {
|
||||
safe_stack: event.safe_stack.filter(
|
||||
(frame) =>
|
||||
!frame.startsWith("/") &&
|
||||
!/^[A-Za-z]:\\/.test(frame) &&
|
||||
!frame.includes(".."),
|
||||
),
|
||||
}),
|
||||
};
|
||||
const details = Object.fromEntries(
|
||||
Object.entries(event.details ?? {}).filter(([key]) =>
|
||||
SAFE_DETAIL_KEYS.has(key),
|
||||
),
|
||||
);
|
||||
if (Object.keys(details).length > 0) redacted.details = details;
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export async function followLogs(options: {
|
||||
@@ -218,7 +315,6 @@ export async function followLogs(options: {
|
||||
...options.query,
|
||||
tail: 5_000,
|
||||
});
|
||||
if (report.failure) throw new Error(report.failure);
|
||||
for (const event of report.events) {
|
||||
const identity = JSON.stringify(event);
|
||||
if (seen.has(identity)) continue;
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "one-memory"
|
||||
version = "1.0.5"
|
||||
version = "1.0.4"
|
||||
description = "MemHub local-first memory core"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"fastapi>=0.141.1",
|
||||
"filelock>=3.32.4",
|
||||
# Public SDK version validated against MemHub's permission and recall contracts.
|
||||
"hy-memory==2.0.5",
|
||||
"hy-memory==2.0.5.dev3",
|
||||
# Retrieval's derived vector index directly consumes these locked versions.
|
||||
"chromadb==1.5.9",
|
||||
"numpy==2.5.2",
|
||||
|
||||
@@ -998,7 +998,9 @@ def create_app(
|
||||
app.state.recall_target = GovernedRecallTarget(
|
||||
app.state.memory_governance_store,
|
||||
filter_memories=app.state.source_read_access.memories,
|
||||
semantic_search=lambda query: _sdk_memory_runtime(app).semantic_recall(query),
|
||||
semantic_search=lambda query: _sdk_memory_runtime(
|
||||
app
|
||||
).semantic_recall(query),
|
||||
# Read per request rather than cached: an import that lands
|
||||
# while the app is open should make its workspace reachable
|
||||
# without a restart. Affordable only because the read is served
|
||||
@@ -1100,7 +1102,9 @@ def create_app(
|
||||
authorize_generation=lambda profile, question, grounding: (
|
||||
_authorize_answer_generation(app, profile, question, grounding)
|
||||
),
|
||||
semantic_search=lambda query: _sdk_memory_runtime(app).semantic_recall(query),
|
||||
semantic_search=lambda query: _sdk_memory_runtime(app).semantic_recall(
|
||||
query
|
||||
),
|
||||
resolve_workspace=lambda workspace_path: resolve_workspace_scopes(
|
||||
workspace_path,
|
||||
evidence_store.known_workspaces(user_id="local"),
|
||||
@@ -1124,21 +1128,21 @@ def create_app(
|
||||
profiles=app.state.model_profile_store,
|
||||
secrets=app.state.secret_store,
|
||||
data_dir=runtime_settings.data_dir,
|
||||
transport_factory=lambda profile: (
|
||||
lambda: AuditedModelTransport(
|
||||
app.state.model_request_audit_store,
|
||||
profile_id=profile.profile_id,
|
||||
profile_revision=profile.revision,
|
||||
provider_kind=str(profile.provider_kind),
|
||||
model=profile.model,
|
||||
inner=(
|
||||
BudgetedModelTransport(model_request_budget)
|
||||
if model_request_budget is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
transport_factory=lambda profile: lambda: AuditedModelTransport(
|
||||
app.state.model_request_audit_store,
|
||||
profile_id=profile.profile_id,
|
||||
profile_revision=profile.revision,
|
||||
provider_kind=str(profile.provider_kind),
|
||||
model=profile.model,
|
||||
inner=(
|
||||
BudgetedModelTransport(model_request_budget)
|
||||
if model_request_budget is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
sedimentation_enabled=lambda: _config_store(app).sedimentation_enabled(),
|
||||
sedimentation_enabled=lambda: _config_store(
|
||||
app
|
||||
).sedimentation_enabled(),
|
||||
)
|
||||
app.state.sdk_memory_runtime = SdkMemoryRuntime(
|
||||
coordinator=app.state.sdk_write_coordinator,
|
||||
@@ -1147,8 +1151,6 @@ def create_app(
|
||||
extraction_settings=app.state.extraction_settings_store,
|
||||
data_dir=runtime_settings.data_dir,
|
||||
)
|
||||
if app.state.memory_authority is MemoryAuthority.SDK:
|
||||
app.state.sdk_memory_runtime.start_raw_retention()
|
||||
# C2 owns the single production budget (handle 5s + reserve 250ms,
|
||||
# total 5.25s, lease 10s, renew 1s, claim 250ms). The composition
|
||||
# root only consumes it: no local formula, and the 250ms claim
|
||||
@@ -1163,8 +1165,10 @@ def create_app(
|
||||
exclude_error_codes=NON_RETRYABLE_POST_WRITE_CODES,
|
||||
)
|
||||
for job in unknown:
|
||||
receipt = await app.state.sdk_write_coordinator.inquire_write_outcome(
|
||||
job.request_id
|
||||
receipt = (
|
||||
await app.state.sdk_write_coordinator.inquire_write_outcome(
|
||||
job.request_id
|
||||
)
|
||||
)
|
||||
if receipt is None:
|
||||
continue
|
||||
@@ -1604,7 +1608,9 @@ def create_app(
|
||||
)
|
||||
if getattr(
|
||||
app.state, "memory_authority", MemoryAuthority.LEGACY
|
||||
) is MemoryAuthority.SDK and request.url.path.startswith(legacy_paths):
|
||||
) is MemoryAuthority.SDK and request.url.path.startswith(
|
||||
legacy_paths
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={"detail": "sdk_memory_authority_required"},
|
||||
@@ -1726,7 +1732,7 @@ def create_app(
|
||||
trace_id=trace_id,
|
||||
instance_id=runtime_settings.instance_id,
|
||||
duration_ms=elapsed_ms(started),
|
||||
details={**diagnostic_details, "status_code": response.status_code},
|
||||
details={**diagnostic_details, "status": str(response.status_code)},
|
||||
)
|
||||
return response
|
||||
except Exception as error:
|
||||
@@ -3775,7 +3781,9 @@ def create_app(
|
||||
all_memories, projected, nodes, edges = await asyncio.to_thread(read_governed_part)
|
||||
try:
|
||||
hy_graph = (
|
||||
None if revoked else await _sdk_memory_runtime(app).graph_snapshot(limit=limit)
|
||||
None
|
||||
if revoked
|
||||
else await _sdk_memory_runtime(app).graph_snapshot(limit=limit)
|
||||
)
|
||||
except HySdkError:
|
||||
hy_graph = None
|
||||
@@ -3884,7 +3892,9 @@ def create_app(
|
||||
active_queue, in_flight_count = supervisor.active_depths()
|
||||
planned, current = supervisor.manual_batch_progress()
|
||||
sdk_counts = (
|
||||
supervisor.status_counts() if app.state.memory_authority is MemoryAuthority.SDK else {}
|
||||
supervisor.status_counts()
|
||||
if app.state.memory_authority is MemoryAuthority.SDK
|
||||
else {}
|
||||
)
|
||||
# `running` counts rows the store still marks running, which includes
|
||||
# work a worker has already given up on. Reporting that as "what is
|
||||
@@ -3905,7 +3915,9 @@ def create_app(
|
||||
),
|
||||
recent_completions=depths.recent_completions,
|
||||
running=sdk_counts.get("running", running_rows),
|
||||
stranded_running=(0 if sdk_counts else max(0, running_rows - in_flight_count)),
|
||||
stranded_running=(
|
||||
0 if sdk_counts else max(0, running_rows - in_flight_count)
|
||||
),
|
||||
unexecuted_policy_failures=store.unexecuted_policy_failures(),
|
||||
waiting_for_model=sdk_counts.get(
|
||||
"waiting_for_model",
|
||||
@@ -5272,7 +5284,7 @@ def _report_visibility_failure(phase: str, error: BaseException) -> None:
|
||||
event="memory_visibility.background_failed",
|
||||
outcome="failed",
|
||||
error_type=type(error).__name__,
|
||||
details={"phase": phase},
|
||||
details={"phase": phase, "reason": str(error)[:200]},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,60 +31,13 @@ def _end_by(seconds: str | None) -> None:
|
||||
if __name__ == "__main__" and sys.argv[1:2] == ["capture"]:
|
||||
_end_by(os.environ.get("ONE_MEMORY_CAPTURE_DEADLINE_S"))
|
||||
|
||||
import errno # noqa: E402
|
||||
import json # noqa: E402
|
||||
import re # noqa: E402
|
||||
from collections.abc import Iterator, Sequence # noqa: E402
|
||||
from collections.abc import Sequence # noqa: E402
|
||||
from typing import TextIO # noqa: E402
|
||||
|
||||
from one_memory.config import load_settings # noqa: E402
|
||||
from one_memory.runtime_client import RuntimeStatus, inspect_runtime # noqa: E402
|
||||
|
||||
# A single `sdk_migration_backfill_failed` told the person nothing: running out
|
||||
# of disk, a model that was never configured, and a Coding/Working-Fact gap are
|
||||
# three different next steps, and the startup dialog can only act on the name it
|
||||
# is given. These are the names that reach it.
|
||||
_SAFE_MIGRATION_CODE = re.compile(r"^[a-z][a-z0-9_]{0,95}$")
|
||||
_OUT_OF_SPACE = frozenset({errno.ENOSPC, getattr(errno, "EDQUOT", errno.ENOSPC)})
|
||||
|
||||
|
||||
def _exception_chain(error: BaseException) -> Iterator[BaseException]:
|
||||
seen: set[int] = set()
|
||||
current: BaseException | None = error
|
||||
while current is not None and id(current) not in seen:
|
||||
seen.add(id(current))
|
||||
yield current
|
||||
current = current.__cause__ or current.__context__
|
||||
|
||||
|
||||
def migration_failure_code(
|
||||
error: BaseException, *, fallback: str = "sdk_migration_backfill_failed"
|
||||
) -> str:
|
||||
"""Name a migration failure; never guess a code from free text.
|
||||
|
||||
Errs toward the caller's generic code: an exception this does not recognize
|
||||
keeps its message out of the document Desktop reads.
|
||||
"""
|
||||
chain = list(_exception_chain(error))
|
||||
for item in chain:
|
||||
if isinstance(item, OSError) and item.errno in _OUT_OF_SPACE:
|
||||
return "sdk_migration_disk_space_insufficient"
|
||||
for item in chain:
|
||||
code = getattr(item, "code", "")
|
||||
if not isinstance(code, str) or not _SAFE_MIGRATION_CODE.match(code):
|
||||
continue
|
||||
if code.startswith(("sdk_data_plane_", "sdk_migration_")):
|
||||
return code
|
||||
if code.startswith("hy_sdk_productivity_"):
|
||||
# The Coding inventory could not be read or classified; that is a
|
||||
# data-shape problem the person cannot fix by retrying.
|
||||
return "sdk_migration_coding_inventory_failed"
|
||||
if code == "hy_sdk_memory_import_failed" and any(
|
||||
"working_fact" in str(part) for part in chain
|
||||
):
|
||||
return "sdk_migration_working_fact_conflict"
|
||||
return fallback
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if sys.argv[1:] == ["--keychain-read"]:
|
||||
@@ -392,16 +345,9 @@ def migration_bundle(options: Sequence[str], stream: TextIO) -> int:
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
"state": "failed",
|
||||
"code": migration_failure_code(error, fallback=error.code),
|
||||
"code": error.code,
|
||||
}
|
||||
_write_restore(stream, as_json, document, f"迁移备份失败:{document['code']}")
|
||||
return 1
|
||||
except OSError as error:
|
||||
# A full disk while copying is the most common first-launch failure and
|
||||
# the only one the person fixes by deleting something.
|
||||
code = migration_failure_code(error, fallback="sdk_migration_bundle_copy_failed")
|
||||
document = {"schema_version": 1, "state": "failed", "code": code}
|
||||
_write_restore(stream, as_json, document, f"迁移备份失败:{code}")
|
||||
_write_restore(stream, as_json, document, f"迁移备份失败:{error.code}")
|
||||
return 1
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
@@ -415,21 +361,6 @@ def migration_bundle(options: Sequence[str], stream: TextIO) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cutover_failure_code(error: BaseException) -> str:
|
||||
"""The cutover's own codes, minus the counts it appends to one of them.
|
||||
|
||||
`sdk_data_plane_cutover_work_pending` carries a JSON body of table names and
|
||||
counts. That belongs in the diagnostic event, not in the single string the
|
||||
startup dialog reads, so it collapses to one name here.
|
||||
"""
|
||||
code = getattr(error, "code", "")
|
||||
if isinstance(code, str) and code.startswith("sdk_data_plane_cutover_work_pending"):
|
||||
return "sdk_migration_work_pending"
|
||||
if isinstance(code, str) and _SAFE_MIGRATION_CODE.match(code):
|
||||
return code
|
||||
return "sdk_migration_cutover_failed"
|
||||
|
||||
|
||||
def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
"""Backfill legacy memory/raw into SDK while keeping legacy authority."""
|
||||
migration_id: str | None = None
|
||||
@@ -530,15 +461,14 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
except SecretStoreUnavailableError:
|
||||
code = "secure_storage_unavailable"
|
||||
except Exception as error:
|
||||
message = str(error)
|
||||
code = (
|
||||
message
|
||||
if message
|
||||
str(error)
|
||||
if str(error)
|
||||
in {
|
||||
"model_profile_not_active",
|
||||
"model_secret_unavailable",
|
||||
}
|
||||
else migration_failure_code(error, fallback="sdk_migration_preflight_failed")
|
||||
else "sdk_migration_preflight_failed"
|
||||
)
|
||||
else:
|
||||
namespace_fingerprint = hashlib.sha256(HY_MEMORY_NAMESPACE.encode()).hexdigest()
|
||||
@@ -567,8 +497,8 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
paths=staging,
|
||||
database_path=database,
|
||||
)
|
||||
except Exception as error:
|
||||
code = migration_failure_code(error, fallback="sdk_migration_staging_failed")
|
||||
except Exception:
|
||||
code = "sdk_migration_staging_failed"
|
||||
staging_data_dir = None
|
||||
|
||||
async def run_backfill() -> BackfillResult:
|
||||
@@ -595,9 +525,13 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
await asyncio.to_thread(lease.close)
|
||||
|
||||
try:
|
||||
result = asyncio.run(run_backfill()) if staging_data_dir is not None else None
|
||||
except Exception as error:
|
||||
code = migration_failure_code(error)
|
||||
result = (
|
||||
asyncio.run(run_backfill())
|
||||
if staging_data_dir is not None
|
||||
else None
|
||||
)
|
||||
except Exception:
|
||||
code = "sdk_migration_backfill_failed"
|
||||
else:
|
||||
if result is None:
|
||||
document = {"schema_version": 1, "state": "failed", "code": code}
|
||||
@@ -711,7 +645,7 @@ def sdk_cutover(options: Sequence[str], stream: TextIO) -> int:
|
||||
database_path=database_path,
|
||||
)
|
||||
except (DataPlaneMigrationError, MigrationBundleRejected, OSError) as error:
|
||||
code = cutover_failure_code(error)
|
||||
code = getattr(error, "code", str(error))
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
"state": "blocked",
|
||||
@@ -774,11 +708,18 @@ def sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
|
||||
migration_id=marker.migration_id,
|
||||
namespace=HY_MEMORY_NAMESPACE,
|
||||
)
|
||||
staging_cleanup = staging.protective_root.exists() or staging.staging_home.exists()
|
||||
staging_cleanup = (
|
||||
staging.protective_root.exists()
|
||||
or staging.staging_home.exists()
|
||||
)
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
"authority": "sdk",
|
||||
"state": ("cleanup_required" if legacy_tables or staging_cleanup else "complete"),
|
||||
"state": (
|
||||
"cleanup_required"
|
||||
if legacy_tables or staging_cleanup
|
||||
else "complete"
|
||||
),
|
||||
"migration_id": marker.migration_id if marker else None,
|
||||
"generation": generation,
|
||||
"legacy_tables": list(legacy_tables),
|
||||
@@ -803,28 +744,11 @@ def sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
|
||||
else 0
|
||||
),
|
||||
}
|
||||
active_migration = (
|
||||
database.execute(
|
||||
"SELECT migration_id,backup_bundle_id "
|
||||
"FROM sdk_data_plane_migrations "
|
||||
"WHERE state IN ('running','blocked','ready') "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if "sdk_data_plane_migrations" in tables
|
||||
else None
|
||||
)
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
"authority": "legacy",
|
||||
"state": "required" if any(counts.values()) else "empty",
|
||||
"migration_id": (
|
||||
str(active_migration["migration_id"]) if active_migration is not None else None
|
||||
),
|
||||
"bundle_id": (
|
||||
str(active_migration["backup_bundle_id"])
|
||||
if active_migration is not None
|
||||
else None
|
||||
),
|
||||
"migration_id": None,
|
||||
"counts": counts,
|
||||
"generation": generation,
|
||||
}
|
||||
|
||||
@@ -20,10 +20,8 @@ from __future__ import annotations
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -74,30 +72,80 @@ class _Refused(OSError):
|
||||
"""The path is not one we are willing to write to, and never will be."""
|
||||
|
||||
|
||||
DiagnosticLevel = Literal["debug", "info", "warn", "error"]
|
||||
DiagnosticLevel = Literal["info", "warn", "error"]
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
|
||||
_EVENT = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+){0,15}$")
|
||||
_DIAGNOSTIC_LOG_NAME = re.compile(
|
||||
r"^diagnostic-(runtime|core|host-bridge|desktop|cli)\.jsonl(?:\.\d+)?$"
|
||||
_DETAIL_KEYS = frozenset(
|
||||
{
|
||||
"action",
|
||||
"attempt",
|
||||
"count",
|
||||
"method",
|
||||
"mode",
|
||||
"phase",
|
||||
"platform",
|
||||
"reason",
|
||||
"result_count",
|
||||
"source_id",
|
||||
"spawned",
|
||||
"status",
|
||||
"version",
|
||||
"parent_session_id",
|
||||
"invalid_lines",
|
||||
"truncated",
|
||||
"open_file_count",
|
||||
"memory_bytes",
|
||||
"thread_count",
|
||||
"database_error_count",
|
||||
"route_path",
|
||||
"sqlite_errorcode",
|
||||
"sqlite_errorname",
|
||||
"file_count",
|
||||
"sqlite_file_count",
|
||||
"byte_file_count",
|
||||
"total_bytes",
|
||||
"copied_bytes",
|
||||
"bytes_per_second",
|
||||
"stored_count",
|
||||
"revised_count",
|
||||
"skipped_count",
|
||||
"error_count",
|
||||
"pending_count",
|
||||
"prepared_count",
|
||||
"queue_depth",
|
||||
"in_flight_count",
|
||||
"call_count",
|
||||
"created_count",
|
||||
"updated_count",
|
||||
"superseded_count",
|
||||
"deleted_count",
|
||||
"operation_kind",
|
||||
"index_complete",
|
||||
"lexical_hits",
|
||||
"semantic_hits",
|
||||
"generation",
|
||||
"timed_out",
|
||||
"failure_site",
|
||||
"error_class",
|
||||
"file_kind",
|
||||
"file_size",
|
||||
"file_mtime_ns",
|
||||
"file_inode",
|
||||
"cursor_new",
|
||||
"cursor_old",
|
||||
"cursor_read_to_end",
|
||||
"cursor_rewind_branch",
|
||||
"summed_ms",
|
||||
"model_ms",
|
||||
"accounted_ms",
|
||||
"unaccounted_ms",
|
||||
"conversation_hash",
|
||||
# The sedimentation switch: a bounded boolean, the same shape as
|
||||
# `spawned` and `index_complete`. Left out of this list it did not
|
||||
# drop a log line, it turned the switch's own request into a 500.
|
||||
"enabled",
|
||||
}
|
||||
)
|
||||
_DETAIL_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,63}$")
|
||||
_CODE_FRAME = re.compile(
|
||||
r"^(?:(?:apps|packages|scripts)/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_.-]+:\d+:\d+"
|
||||
r"|one_memory/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_.-]+:\d+:[A-Za-z_][A-Za-z0-9_]*"
|
||||
r"|[A-Za-z0-9_][A-Za-z0-9_.-]*\.py:\d+:[A-Za-z_][A-Za-z0-9_]*)$"
|
||||
)
|
||||
_SHORT_CREDENTIAL_TOKEN = re.compile(
|
||||
r"(?<![A-Za-z0-9])([A-Za-z0-9]{24,47})(?:[_-])?(?![A-Za-z0-9])"
|
||||
)
|
||||
_REFUSED_DETAIL_KEYS = frozenset({"__proto__", "prototype", "constructor"})
|
||||
_MAX_DETAIL_KEYS = 32
|
||||
_MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
_EXPORT_SECRET_MARKER = re.compile(
|
||||
r"(?:^|[._:-])(?:authorization|credential|password|prompt|query|secret|token)(?:$|[._:-])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TRACE_STRING_DETAIL_KEYS = frozenset({"phase", "mode", "status", "reason", "failure_site"})
|
||||
_TRACE_NUMBER_DETAIL_KEYS = frozenset({"count"})
|
||||
_trace_id: ContextVar[str | None] = ContextVar("one_memory_trace_id", default=None)
|
||||
_active: DiagnosticLog | None = None
|
||||
_database_errors: Counter[tuple[str, str]] = Counter()
|
||||
@@ -216,12 +264,7 @@ class DiagnosticLog:
|
||||
}
|
||||
value.update({key: _identifier(item) for key, item in identifiers.items() if item})
|
||||
if duration_ms is not None:
|
||||
if (
|
||||
not isinstance(duration_ms, int)
|
||||
or isinstance(duration_ms, bool)
|
||||
or duration_ms < 0
|
||||
or duration_ms > _MAX_SAFE_INTEGER
|
||||
):
|
||||
if not isinstance(duration_ms, int) or duration_ms < 0:
|
||||
raise ValueError("diagnostic_duration_invalid")
|
||||
value["duration_ms"] = duration_ms
|
||||
for field_name, item in {
|
||||
@@ -233,9 +276,7 @@ class DiagnosticLog:
|
||||
if item is not None:
|
||||
value[field_name] = _identifier(item)
|
||||
if safe_stack:
|
||||
if len(safe_stack) > 16:
|
||||
raise ValueError("diagnostic_stack_invalid")
|
||||
value["safe_stack"] = [_safe_stack_line(line) for line in safe_stack]
|
||||
value["safe_stack"] = [_safe_stack_line(line) for line in safe_stack[:256]]
|
||||
if details:
|
||||
value["details"] = _details(details)
|
||||
self._submit(_serialize(value))
|
||||
@@ -248,8 +289,6 @@ class DiagnosticLog:
|
||||
event: str,
|
||||
outcome: str,
|
||||
) -> dict[str, object]:
|
||||
if level not in ("debug", "info", "warn", "error"):
|
||||
raise ValueError("diagnostic_level_invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||
@@ -612,17 +651,24 @@ def _drain_diagnostics_at_exit() -> None:
|
||||
|
||||
|
||||
def diagnostic_event(**values: object) -> None:
|
||||
active = _active
|
||||
if active is None:
|
||||
return
|
||||
if values.get("level") == "debug" and not logging.getLogger().isEnabledFor(logging.DEBUG):
|
||||
return
|
||||
try:
|
||||
active = _active
|
||||
if active is None:
|
||||
return
|
||||
if values.get("level") == "debug" and not logging.getLogger().isEnabledFor(logging.DEBUG):
|
||||
return
|
||||
active.write(**values) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
# Diagnostics are best-effort: validation, serialization, logging, and
|
||||
# filesystem failures must never become a product outage. BaseException
|
||||
# deliberately remains visible so process-control signals still work.
|
||||
except OSError:
|
||||
# Diagnostics must not become a new product outage when a disk is full
|
||||
# or the log directory is temporarily unavailable.
|
||||
return
|
||||
except ValueError:
|
||||
# Same promise, same reason, for a rejected payload instead of a failed
|
||||
# write. An unlisted `details` key once turned every external recall
|
||||
# into a 500 and then killed the process, and the sedimentation switch
|
||||
# into a permanent failure. The payload itself is still wrong and is
|
||||
# still caught in CI by tests/test_diagnostic_payload_keys.py; what
|
||||
# must not happen is the caller paying for it.
|
||||
return
|
||||
|
||||
|
||||
@@ -645,95 +691,46 @@ def read_trace_events(
|
||||
if (request_id is None) == (trace_id is None):
|
||||
raise ValueError("exactly_one_trace_identity_required")
|
||||
key, expected = ("request_id", request_id) if request_id is not None else ("trace_id", trace_id)
|
||||
if expected is None or not _export_identifier(expected):
|
||||
if expected is None or not _IDENTIFIER.fullmatch(expected):
|
||||
raise ValueError("invalid_trace_identity")
|
||||
selected: list[dict[str, object]] = []
|
||||
directory = data_dir / "logs"
|
||||
try:
|
||||
directory_info = directory.lstat()
|
||||
except FileNotFoundError:
|
||||
return ()
|
||||
if stat.S_ISLNK(directory_info.st_mode) or not stat.S_ISDIR(directory_info.st_mode):
|
||||
raise ValueError("diagnostic_directory_unsafe")
|
||||
files: list[tuple[Path, float]] = []
|
||||
for path in directory.iterdir():
|
||||
if not _DIAGNOSTIC_LOG_NAME.fullmatch(path.name):
|
||||
allowed_details = {"phase", "mode", "status", "reason", "failure_site", "count"}
|
||||
files = sorted(
|
||||
(data_dir / "logs").glob("diagnostic-*.jsonl*"),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
)
|
||||
for path in files:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
continue
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError:
|
||||
continue
|
||||
if stat.S_ISREG(info.st_mode) and not stat.S_ISLNK(info.st_mode):
|
||||
files.append((path, info.st_mtime))
|
||||
for path, _mtime in sorted(files, key=lambda item: item[1]):
|
||||
for line in _read_regular_file_no_follow(path).splitlines():
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
if not isinstance(event, dict) or event.get(key) != expected:
|
||||
continue
|
||||
timestamp = event.get("timestamp")
|
||||
if not isinstance(timestamp, str):
|
||||
continue
|
||||
try:
|
||||
datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
continue
|
||||
component = event.get("component")
|
||||
event_name = event.get("event")
|
||||
outcome = event.get("outcome")
|
||||
projected: dict[str, object] = {
|
||||
"timestamp": timestamp,
|
||||
"component": (
|
||||
component
|
||||
if isinstance(component, str) and _export_identifier(component)
|
||||
else "diagnostic.redacted"
|
||||
),
|
||||
"event": (
|
||||
event_name
|
||||
if isinstance(event_name, str) and _export_identifier(event_name)
|
||||
else "diagnostic.redacted"
|
||||
),
|
||||
"outcome": (
|
||||
outcome
|
||||
if isinstance(outcome, str) and _export_identifier(outcome)
|
||||
else "redacted"
|
||||
),
|
||||
}
|
||||
duration_ms = event.get("duration_ms")
|
||||
if (
|
||||
isinstance(duration_ms, int)
|
||||
and not isinstance(duration_ms, bool)
|
||||
and 0 <= duration_ms <= _MAX_SAFE_INTEGER
|
||||
):
|
||||
projected["duration_ms"] = duration_ms
|
||||
for name in ("error_code", "error_type"):
|
||||
value = event.get(name)
|
||||
if isinstance(value, str) and _export_identifier(value):
|
||||
projected[name] = value
|
||||
details = event.get("details")
|
||||
projected = {
|
||||
name: event[name]
|
||||
for name in (
|
||||
"timestamp",
|
||||
"component",
|
||||
"event",
|
||||
"outcome",
|
||||
"duration_ms",
|
||||
"error_code",
|
||||
"error_type",
|
||||
)
|
||||
if name in event
|
||||
}
|
||||
if isinstance(details, dict):
|
||||
safe_details = _trace_details(details)
|
||||
if safe_details:
|
||||
projected["details"] = safe_details
|
||||
projected["details"] = {
|
||||
name: value for name, value in details.items() if name in allowed_details
|
||||
}
|
||||
selected.append(projected)
|
||||
return tuple(selected[-max(1, min(limit, 500)) :])
|
||||
|
||||
|
||||
def _read_regular_file_no_follow(path: Path) -> str:
|
||||
descriptor = os.open(path, os.O_RDONLY | _O_NOFOLLOW)
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
||||
raise ValueError("diagnostic_file_unsafe")
|
||||
with os.fdopen(descriptor, encoding="utf-8", errors="replace") as handle:
|
||||
descriptor = -1
|
||||
return handle.read()
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def current_trace_id() -> str | None:
|
||||
return _trace_id.get()
|
||||
|
||||
@@ -840,77 +837,8 @@ def diagnostic_exception(error: BaseException) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _contains_credential_material(value: str) -> bool:
|
||||
return bool(
|
||||
re.search(
|
||||
r"(?:^|[^A-Za-z0-9])(?:sk-|sk_|omhg_|ghp_|github_pat_)[A-Za-z0-9._-]+",
|
||||
value,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
or re.search(r"(?:^|[^A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]+", value, re.IGNORECASE)
|
||||
or re.search(r"(?:^|[^A-Za-z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Za-z0-9])", value)
|
||||
or re.search(r"(?:^|[^A-Za-z0-9])AKID[A-Za-z0-9]{12,}", value, re.IGNORECASE)
|
||||
or re.search(r"(?:^|[^A-Za-z0-9])AIza[A-Za-z0-9_-]{20,}", value)
|
||||
or re.search(r"(?:^|[^A-Za-z0-9])GOCSPX-[A-Za-z0-9_-]+", value)
|
||||
or any(
|
||||
_is_conservative_short_credential(match.group(1))
|
||||
for match in _SHORT_CREDENTIAL_TOKEN.finditer(value)
|
||||
)
|
||||
or re.search(
|
||||
r"\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b",
|
||||
value,
|
||||
)
|
||||
or any(
|
||||
len(candidate) >= 48
|
||||
and any(character.isalpha() for character in candidate)
|
||||
and any(character.isdigit() for character in candidate)
|
||||
for candidate in re.findall(r"\b[A-Za-z0-9._-]{48,}\b", value)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _is_conservative_short_credential(value: str) -> bool:
|
||||
return bool(
|
||||
re.search(r"[A-Z]", value)
|
||||
and re.search(r"[a-z]", value)
|
||||
and re.search(r"\d", value)
|
||||
and not re.fullmatch(r"[A-Fa-f0-9]+", value)
|
||||
)
|
||||
|
||||
|
||||
def _export_identifier(value: str) -> bool:
|
||||
return bool(
|
||||
_IDENTIFIER.fullmatch(value)
|
||||
and not _EXPORT_SECRET_MARKER.search(value)
|
||||
and not _contains_credential_material(value)
|
||||
)
|
||||
|
||||
|
||||
def _trace_details(values: Mapping[object, object]) -> dict[str, str | int | float]:
|
||||
result: dict[str, str | int | float] = {}
|
||||
for key, value in values.items():
|
||||
if (
|
||||
key in _TRACE_STRING_DETAIL_KEYS
|
||||
and isinstance(value, str)
|
||||
and _export_identifier(value)
|
||||
):
|
||||
result[str(key)] = value
|
||||
elif key in _TRACE_NUMBER_DETAIL_KEYS and isinstance(value, (int, float)):
|
||||
if isinstance(value, bool):
|
||||
continue
|
||||
safe_integer = isinstance(value, int) and abs(value) <= _MAX_SAFE_INTEGER
|
||||
safe_float = (
|
||||
isinstance(value, float)
|
||||
and math.isfinite(value)
|
||||
and (not value.is_integer() or abs(value) <= _MAX_SAFE_INTEGER)
|
||||
)
|
||||
if safe_integer or safe_float:
|
||||
result[str(key)] = value
|
||||
return result
|
||||
|
||||
|
||||
def _serialize(value: Mapping[str, object]) -> str:
|
||||
return json.dumps(value, ensure_ascii=True, allow_nan=False, separators=(",", ":")) + "\n"
|
||||
return json.dumps(value, ensure_ascii=True, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def _uuid(value: str | None) -> str | None:
|
||||
@@ -936,47 +864,24 @@ def _event_name(value: str) -> str:
|
||||
|
||||
|
||||
def _safe_stack_line(value: str) -> str:
|
||||
if (
|
||||
not value
|
||||
or len(value) > 256
|
||||
or any(character in value for character in "\0\r\n")
|
||||
or _contains_credential_material(value)
|
||||
):
|
||||
if not value or len(value) > 256 or "\n" in value or "\r" in value:
|
||||
raise ValueError("diagnostic_stack_invalid")
|
||||
if _CODE_FRAME.fullmatch(value):
|
||||
return value
|
||||
if value.startswith("thread|"):
|
||||
frames = value.removeprefix("thread|").split(" > ")
|
||||
if frames and all(_CODE_FRAME.fullmatch(frame) for frame in frames):
|
||||
return value
|
||||
raise ValueError("diagnostic_stack_invalid")
|
||||
if Path(value.split(":", 1)[0]).is_absolute() or "file://" in value:
|
||||
raise ValueError("diagnostic_stack_invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _details(
|
||||
values: Mapping[str, str | int | float | bool | None],
|
||||
) -> dict[str, str | int | float | bool | None]:
|
||||
if len(values) > _MAX_DETAIL_KEYS:
|
||||
raise ValueError("diagnostic_detail_count_invalid")
|
||||
result: dict[str, str | int | float | bool | None] = {}
|
||||
for key, value in values.items():
|
||||
if (
|
||||
not isinstance(key, str)
|
||||
or not _DETAIL_KEY.fullmatch(key)
|
||||
or key in _REFUSED_DETAIL_KEYS
|
||||
):
|
||||
if key not in _DETAIL_KEYS:
|
||||
raise ValueError("diagnostic_detail_key_invalid")
|
||||
if isinstance(value, str):
|
||||
if not value or len(value) > 256 or any(character in value for character in "\0\r\n"):
|
||||
if not value or len(value) > 256 or "\n" in value or "\r" in value:
|
||||
raise ValueError("diagnostic_detail_value_invalid")
|
||||
elif isinstance(value, float):
|
||||
if not math.isfinite(value) or (value.is_integer() and abs(value) > _MAX_SAFE_INTEGER):
|
||||
raise ValueError("diagnostic_detail_value_invalid")
|
||||
elif isinstance(value, bool) or value is None:
|
||||
pass
|
||||
elif isinstance(value, int):
|
||||
if abs(value) > _MAX_SAFE_INTEGER:
|
||||
raise ValueError("diagnostic_detail_value_invalid")
|
||||
else:
|
||||
elif value is not None and not isinstance(value, (int, float, bool)):
|
||||
raise ValueError("diagnostic_detail_value_invalid")
|
||||
result[key] = value
|
||||
return result
|
||||
@@ -3,8 +3,8 @@
|
||||
Every site that catches `Exception` broadly must call `report_exception`
|
||||
before (or instead of) continuing, so a failure never looks like success
|
||||
and a stack is always on disk. Content (conversation text, keys, SQL
|
||||
parameters, and exception messages) never reaches the diagnostic log: only
|
||||
exception type, a repeat count, and file:line:function frames.
|
||||
parameters) never reaches the log: only exception type, a truncated
|
||||
message, and file:line:function frames.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -67,7 +67,7 @@ def report_exception(
|
||||
event=event,
|
||||
outcome="failed",
|
||||
**payload,
|
||||
details={"count": _RECENT[key]},
|
||||
details={"reason": _safe_message(error), "count": _RECENT[key]},
|
||||
)
|
||||
if full:
|
||||
_logger.error(
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.5"
|
||||
HY_MEMORY_VERSION = "2.0.5.dev3"
|
||||
HY_MEMORY_NAMESPACE = "governed-v2"
|
||||
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
||||
|
||||
|
||||
@@ -58,16 +58,6 @@ class MigrationClient(Protocol):
|
||||
|
||||
def get_write_outcome(self, request_id: str) -> Mapping[str, object] | None: ...
|
||||
|
||||
def get(
|
||||
self,
|
||||
memory_id: str,
|
||||
*,
|
||||
kind: str | None = None,
|
||||
variant: str | None = None,
|
||||
) -> Mapping[str, object] | None: ...
|
||||
|
||||
async def _coding_store_for_exact_get(self, variant: str) -> Any: ...
|
||||
|
||||
|
||||
class HySdkMigration:
|
||||
"""Typed offline adapter over one initialized SDK data plane."""
|
||||
@@ -101,138 +91,6 @@ class HySdkMigration:
|
||||
)
|
||||
return _memory_page(raw, limit=limit, offset=offset)
|
||||
|
||||
async def list_productivity_memories(self) -> tuple[dict[str, object], ...]:
|
||||
raw = await _sdk_call(
|
||||
"hy_sdk_productivity_inventory_failed",
|
||||
self._loop_thread.run,
|
||||
self._list_productivity_memories(),
|
||||
)
|
||||
return tuple(raw)
|
||||
|
||||
async def _list_productivity_memories(self) -> list[dict[str, object]]:
|
||||
records: list[dict[str, object]] = []
|
||||
for variant in ("working", "coding"):
|
||||
store = await self.client._coding_store_for_exact_get(variant)
|
||||
if store is None:
|
||||
continue
|
||||
with store._lock:
|
||||
experiences = store._conn.execute(
|
||||
"SELECT experience_id,user_id,agent_id,solution "
|
||||
"FROM coding_memory_meta ORDER BY experience_id"
|
||||
).fetchall()
|
||||
facts = store._conn.execute(
|
||||
"SELECT fact_id,user_id,agent_id,statement,status "
|
||||
"FROM working_fact ORDER BY fact_id"
|
||||
).fetchall()
|
||||
records.extend(
|
||||
{
|
||||
"memory_id": str(row["experience_id"]),
|
||||
"content": str(row["solution"]),
|
||||
"status": "active",
|
||||
"user_id": str(row["user_id"]),
|
||||
"agent_id": str(row["agent_id"]),
|
||||
"kind": "experience",
|
||||
"variant": variant,
|
||||
}
|
||||
for row in experiences
|
||||
)
|
||||
records.extend(
|
||||
{
|
||||
"memory_id": str(row["fact_id"]),
|
||||
"content": str(row["statement"]),
|
||||
"status": str(row["status"]),
|
||||
"user_id": str(row["user_id"]),
|
||||
"agent_id": str(row["agent_id"]),
|
||||
"kind": "fact",
|
||||
"variant": variant,
|
||||
}
|
||||
for row in facts
|
||||
)
|
||||
return records
|
||||
|
||||
async def read_productivity_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
*,
|
||||
kind: str,
|
||||
variant: str | None,
|
||||
) -> tuple[HySdkMemoryRecord, str] | None:
|
||||
raw = await _sdk_call(
|
||||
"hy_sdk_productivity_get_failed",
|
||||
self.client.get,
|
||||
memory_id,
|
||||
kind=kind,
|
||||
variant=variant,
|
||||
)
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, Mapping) or raw.get("variant") not in {
|
||||
"working",
|
||||
"coding",
|
||||
}:
|
||||
raise HySdkMigrationError("hy_sdk_productivity_variant_invalid")
|
||||
return HySdkMemoryRecord.from_sdk(raw), str(raw["variant"])
|
||||
|
||||
async def delete_productivity_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
*,
|
||||
kind: str,
|
||||
variant: str | None,
|
||||
user_id: str,
|
||||
) -> dict[str, object]:
|
||||
raw = await _sdk_call(
|
||||
"hy_sdk_productivity_delete_failed",
|
||||
self._loop_thread.run,
|
||||
self._delete_productivity_memory(
|
||||
memory_id,
|
||||
kind=kind,
|
||||
variant=variant,
|
||||
user_id=user_id,
|
||||
),
|
||||
)
|
||||
return _successful_result(raw, "hy_sdk_productivity_delete_failed")
|
||||
|
||||
async def _delete_productivity_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
*,
|
||||
kind: str,
|
||||
variant: str | None,
|
||||
user_id: str,
|
||||
) -> dict[str, object]:
|
||||
if kind not in {"experience", "fact"}:
|
||||
raise ValueError("productivity kind must be experience or fact")
|
||||
matches: list[tuple[Any, str]] = []
|
||||
for candidate in ((variant,) if variant is not None else ("working", "coding")):
|
||||
store = await self.client._coding_store_for_exact_get(candidate)
|
||||
if store is None:
|
||||
continue
|
||||
existing = (
|
||||
await store.get_by_id(memory_id, user_id=user_id)
|
||||
if kind == "experience"
|
||||
else await store.get_fact(memory_id, user_id=user_id)
|
||||
)
|
||||
if existing is not None:
|
||||
matches.append((store, candidate))
|
||||
if len(matches) > 1:
|
||||
raise ValueError("productivity memory id is ambiguous across variants")
|
||||
if not matches:
|
||||
return {"success": True, "deleted_count": 0}
|
||||
store, _actual_variant = matches[0]
|
||||
if kind == "experience":
|
||||
deleted = await store.delete(memory_id, user_id=user_id)
|
||||
elif kind == "fact":
|
||||
await store._fact_vec.delete(memory_id)
|
||||
with store._lock:
|
||||
store._conn.execute(
|
||||
"DELETE FROM working_fact WHERE fact_id=? AND user_id=?",
|
||||
(memory_id, user_id),
|
||||
)
|
||||
store._conn.commit()
|
||||
deleted = True
|
||||
return {"success": True, "deleted_count": int(deleted)}
|
||||
|
||||
async def write_legacy_memory(
|
||||
self,
|
||||
*,
|
||||
@@ -409,7 +267,7 @@ class HySdkMigration:
|
||||
nodes = await self._vector_store.list_by_user(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
status_filter=list(MemoryStatus),
|
||||
status_filter=[MemoryStatus.ACTIVE],
|
||||
layers=[MemoryLayer.L1_RAW],
|
||||
include_sources=[source] if source else None,
|
||||
limit=100_000,
|
||||
@@ -460,21 +318,6 @@ class HySdkMigration:
|
||||
)
|
||||
return _successful_result(raw, "hy_sdk_raw_import_failed")
|
||||
|
||||
async def delete_raw(self, raw_id: str) -> dict[str, object]:
|
||||
raw = await _sdk_call(
|
||||
"hy_sdk_raw_delete_failed",
|
||||
self._loop_thread.run,
|
||||
self._delete_raw(raw_id),
|
||||
)
|
||||
return _successful_result(raw, "hy_sdk_raw_delete_failed")
|
||||
|
||||
async def _delete_raw(self, raw_id: str) -> dict[str, object]:
|
||||
existing = await self._vector_store.get_by_id(raw_id)
|
||||
if existing is None or existing.layer is not MemoryLayer.L1_RAW:
|
||||
return {"success": True, "deleted_count": 0}
|
||||
deleted = await self._vector_store.delete(raw_id)
|
||||
return {"success": True, "deleted_count": int(bool(deleted))}
|
||||
|
||||
async def _write_legacy_raw(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -557,10 +557,12 @@ class PollingLiveSyncSupervisor:
|
||||
)
|
||||
|
||||
def _note_internal_error(self, source_id: str, error: Exception) -> None:
|
||||
# The ordinary local log keeps the exception message for debugging.
|
||||
# The structured diagnostic event deliberately keeps only the stable
|
||||
# error code, source id, type, and relative stack: exception messages
|
||||
# can contain paths, query text, or credentials and are exportable data.
|
||||
# Type alone used to be all this said. It is not enough to act on:
|
||||
# `OperationalError` covers a locked database, a missing table and a
|
||||
# corrupt page alike, and without the message and the frames two dozen
|
||||
# of these in a log could only be guessed at. The stack is the
|
||||
# redacted, `one_memory`-relative one, so it names the call site
|
||||
# without printing anyone's paths or query text.
|
||||
logging.getLogger(__name__).warning(
|
||||
"live_sync_internal_error %s: %s", type(error).__name__, error
|
||||
)
|
||||
@@ -570,7 +572,10 @@ class PollingLiveSyncSupervisor:
|
||||
event="live_sync.internal_error",
|
||||
outcome="failed",
|
||||
error_code="live_sync_internal_error",
|
||||
details={"source_id": source_id},
|
||||
# `reason` is the listed key that can carry the message; see
|
||||
# `diagnostic_log._DETAIL_KEYS`. Truncated because a detail value
|
||||
# longer than 256 characters is rejected.
|
||||
details={"source_id": source_id, "reason": str(error)[:200]},
|
||||
**diagnostic_exception(error),
|
||||
)
|
||||
if isinstance(error, sqlite3.Error):
|
||||
|
||||
@@ -867,7 +867,10 @@ class ImportRunner:
|
||||
outcome="failed",
|
||||
job_id=job_id,
|
||||
error_code="import_heartbeat_failed",
|
||||
details={"phase": "heartbeat"},
|
||||
# `reason` is the listed key for this; see
|
||||
# `diagnostic_log._DETAIL_KEYS`. Truncated: a detail value
|
||||
# over 256 characters is rejected.
|
||||
details={"reason": str(error)[:200]},
|
||||
**diagnostic_exception(error),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Write visible hook text into Evidence. Tool output stays out."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
@@ -75,7 +74,6 @@ _FILE_FALLBACK = {
|
||||
}
|
||||
_FALLBACK_EVENTS = frozenset({"Stop", "stop", "SubagentStop", "StopFailure"})
|
||||
_TEXT_BLOCKS = frozenset({"text"})
|
||||
SDK_MIGRATION_CAPTURE_FENCE = ".sdk-migration-capture-fence.json"
|
||||
|
||||
|
||||
def database_path() -> Path:
|
||||
@@ -90,42 +88,17 @@ def capture_stdio(source_id: str, stdin: TextIO, output: TextIO) -> int:
|
||||
del output
|
||||
if source_id not in CAPTURE_SOURCES:
|
||||
return 2
|
||||
database = database_path()
|
||||
if sdk_migration_capture_fenced(database.parent):
|
||||
return 0
|
||||
try:
|
||||
payload = json.loads(stdin.read() or "")
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
try:
|
||||
capture_hook(source_id, payload, database)
|
||||
capture_hook(source_id, payload, database_path())
|
||||
except Exception:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def sdk_migration_capture_fenced(data_dir: Path) -> bool:
|
||||
"""Ignore stale fences; only a live Desktop migration pauses hook writes."""
|
||||
marker = data_dir / SDK_MIGRATION_CAPTURE_FENCE
|
||||
try:
|
||||
document = json.loads(marker.read_text(encoding="utf-8"))
|
||||
pid = int(document["pid"])
|
||||
if pid < 1:
|
||||
raise ValueError("invalid migration fence pid")
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError, OSError):
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
|
||||
def capture_hook(source_id: str, payload: object, database: Path) -> bool:
|
||||
if source_id not in CAPTURE_SOURCES or not isinstance(payload, dict):
|
||||
return False
|
||||
|
||||
@@ -14,7 +14,6 @@ stuck in it.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -24,7 +23,6 @@ from types import FrameType
|
||||
logger = logging.getLogger("one_memory.loop_watchdog")
|
||||
|
||||
DEFAULT_STALL_SECONDS = 2.0
|
||||
_SAFE_FALLBACK_FILE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*\.py$")
|
||||
|
||||
|
||||
class LoopWatchdog:
|
||||
@@ -122,28 +120,22 @@ def _safe_stack_for_main_thread() -> tuple[str, ...]:
|
||||
|
||||
|
||||
def _safe_stacks_for_all_threads() -> tuple[str, ...]:
|
||||
"""Return bounded stack summaries without recording thread names."""
|
||||
"""Return one content-free, bounded stack summary for every live thread."""
|
||||
names = {thread.ident: thread.name for thread in threading.enumerate()}
|
||||
lines: list[str] = []
|
||||
for _ident, frame in sorted(sys._current_frames().items()):
|
||||
for ident, frame in sorted(sys._current_frames().items()):
|
||||
stack: list[str] = []
|
||||
# Most specific frame first; append only complete frames so truncation
|
||||
# can never turn a valid location into free text.
|
||||
# Most specific frame first so the 256-character privacy bound keeps
|
||||
# the likely culprit rather than only the thread bootstrap frames.
|
||||
for entry in reversed(traceback.extract_stack(frame)[-8:]):
|
||||
parts = entry.filename.replace("\\", "/").split("/")
|
||||
try:
|
||||
root = parts.index("one_memory")
|
||||
relative = "/".join(parts[root:])
|
||||
except ValueError:
|
||||
fallback = parts[-1]
|
||||
relative = fallback if _SAFE_FALLBACK_FILE.fullmatch(fallback) else "unknown.py"
|
||||
relative = parts[-1]
|
||||
name = entry.name if entry.name.isidentifier() else "call"
|
||||
candidate = f"{relative}:{entry.lineno}:{name}"
|
||||
separator = "" if not stack else " > "
|
||||
if len("thread|") + len(" > ".join(stack)) + len(separator) + len(candidate) > 256:
|
||||
break
|
||||
stack.append(candidate)
|
||||
if stack:
|
||||
lines.append(f"thread|{' > '.join(stack)}")
|
||||
if len(lines) == 16:
|
||||
break
|
||||
stack.append(f"{relative}:{entry.lineno}:{name}")
|
||||
thread_name = names.get(ident, "unknown").replace("\n", " ").replace("\r", " ")
|
||||
lines.append(f"thread={thread_name[:48]}|{' > '.join(stack)}"[:256])
|
||||
return tuple(lines)
|
||||
@@ -3,8 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -27,18 +25,6 @@ from one_memory.memory_server.durable_queue import COMPLETED, DurableWriteQueue
|
||||
from one_memory.sdk_write_processor import SdkWriteCoordinator
|
||||
from one_memory.semantic_deadline import SemanticMemoryHit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
RAW_RETENTION_DAYS = 3
|
||||
RAW_RETENTION_CHECK_SECONDS = 6 * 60 * 60
|
||||
_RAW_STATUSES = (
|
||||
"active",
|
||||
"superseded",
|
||||
"shadow",
|
||||
"negated",
|
||||
"conflicted",
|
||||
"archived",
|
||||
)
|
||||
|
||||
|
||||
class SdkMemoryRuntime:
|
||||
"""Product services that remain after legacy task/governance writes retire."""
|
||||
@@ -57,68 +43,6 @@ class SdkMemoryRuntime:
|
||||
self.write_queue = write_queue
|
||||
self.extraction_settings = extraction_settings
|
||||
self.data_dir = data_dir
|
||||
self._raw_retention_task: asyncio.Task[None] | None = None
|
||||
|
||||
def start_raw_retention(self) -> None:
|
||||
if self._raw_retention_task is None or self._raw_retention_task.done():
|
||||
self._raw_retention_task = asyncio.create_task(
|
||||
self._raw_retention_loop(),
|
||||
name="one-memory-sdk-raw-retention",
|
||||
)
|
||||
|
||||
async def prune_expired_raw(self, *, now: datetime | None = None) -> int:
|
||||
cutoff = (now or datetime.now(UTC)) - timedelta(days=RAW_RETENTION_DAYS)
|
||||
cutoff_timestamp = int(cutoff.timestamp())
|
||||
client = await self.read_handle.client()
|
||||
deleted = 0
|
||||
while True:
|
||||
page = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
layers=["l1_raw"],
|
||||
statuses=list(_RAW_STATUSES),
|
||||
order="asc",
|
||||
limit=500,
|
||||
offset=0,
|
||||
)
|
||||
bucket = page.get("vdb") if isinstance(page, dict) else None
|
||||
rows = bucket.get("memories") if isinstance(bucket, dict) else None
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return deleted
|
||||
expired = [
|
||||
row
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
and isinstance(row.get("memory_id"), str)
|
||||
and int(row.get("memory_at") or row.get("gmt_created") or 0)
|
||||
< cutoff_timestamp
|
||||
]
|
||||
if not expired:
|
||||
return deleted
|
||||
removed_page = 0
|
||||
for row in expired:
|
||||
result = await asyncio.to_thread(client.delete, row["memory_id"])
|
||||
if isinstance(result, dict) and result.get("success") is True:
|
||||
count = int(result.get("deleted_count") or 0)
|
||||
deleted += count
|
||||
removed_page += count
|
||||
if removed_page == 0:
|
||||
return deleted
|
||||
|
||||
async def _raw_retention_loop(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(RAW_RETENTION_CHECK_SECONDS)
|
||||
try:
|
||||
removed = await self.prune_expired_raw()
|
||||
if removed:
|
||||
logger.info("sdk_raw_retention_deleted count=%d", removed)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"sdk_raw_retention_failed error_type=%s",
|
||||
type(error).__name__,
|
||||
)
|
||||
|
||||
async def semantic_recall(
|
||||
self,
|
||||
@@ -355,12 +279,7 @@ class SdkMemoryRuntime:
|
||||
return None
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._raw_retention_task is None:
|
||||
return
|
||||
self._raw_retention_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._raw_retention_task
|
||||
self._raw_retention_task = None
|
||||
return None
|
||||
|
||||
|
||||
def _agent_ids(raw: object) -> tuple[str, ...]:
|
||||
|
||||
@@ -7,13 +7,12 @@ import json
|
||||
import sqlite3
|
||||
from collections.abc import Awaitable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.hy_sdk_types import HySdkMemoryPage, HySdkMemoryRecord, HySdkRawPage
|
||||
from one_memory.input_sanitization import sanitize_model_input
|
||||
from one_memory.hy_sdk_types import HySdkMemoryPage, HySdkRawPage
|
||||
from one_memory.sdk_data_plane_migration import SdkDataPlaneMigrationStore
|
||||
|
||||
|
||||
@@ -36,8 +35,6 @@ class LegacyMemoryInventory:
|
||||
tags: tuple[str, ...]
|
||||
supersedes: tuple[str, ...]
|
||||
superseded_by: tuple[str, ...]
|
||||
productivity_kind: str | None = None
|
||||
productivity_variant: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -47,8 +44,6 @@ class SdkMemoryInventory:
|
||||
agent_id: str
|
||||
content_hash: str
|
||||
status: str
|
||||
productivity_kind: str | None = None
|
||||
productivity_variant: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -97,17 +92,6 @@ _KIND_LAYER = {
|
||||
"schema": "l6_schema",
|
||||
"intention": "l7_intention",
|
||||
}
|
||||
_PRODUCTIVITY_LAYER = {
|
||||
"coding_experience": ("experience", "coding"),
|
||||
"working_experience": ("experience", "working"),
|
||||
"coding_fact": ("fact", "coding"),
|
||||
# Historical receipts called every productivity fact "working_fact",
|
||||
# including facts stored beside a coding-variant experience. Resolve the
|
||||
# actual variant from the SDK exact lookup instead of trusting this label.
|
||||
"working_fact": ("fact", None),
|
||||
}
|
||||
_RAW_TURN_MAX_CHARACTERS = 60_000
|
||||
_RAW_USER_MAX_CHARACTERS = 30_000
|
||||
|
||||
SDK_LIFECYCLE_STATUSES = (
|
||||
"active",
|
||||
@@ -177,29 +161,6 @@ class SdkMigrationClient(Protocol):
|
||||
|
||||
def delete(self, memory_id: str) -> Awaitable[dict[str, object]]: ...
|
||||
|
||||
def read_productivity_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
*,
|
||||
kind: str,
|
||||
variant: str | None,
|
||||
) -> Awaitable[tuple[HySdkMemoryRecord, str] | None]: ...
|
||||
|
||||
def list_productivity_memories(
|
||||
self,
|
||||
) -> Awaitable[tuple[dict[str, object], ...]]: ...
|
||||
|
||||
def delete_productivity_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
*,
|
||||
kind: str,
|
||||
variant: str | None,
|
||||
user_id: str,
|
||||
) -> Awaitable[dict[str, object]]: ...
|
||||
|
||||
def delete_raw(self, raw_id: str) -> Awaitable[dict[str, object]]: ...
|
||||
|
||||
|
||||
def read_legacy_memory_inventory(database_path: Path) -> tuple[LegacyMemoryInventory, ...]:
|
||||
"""Read legacy memory intent without constructing a Governance writer."""
|
||||
@@ -243,7 +204,6 @@ def read_legacy_memory_inventory(database_path: Path) -> tuple[LegacyMemoryInven
|
||||
tags = tuple(value for value in tags_value if isinstance(value, str))
|
||||
kind = str(row["kind"])
|
||||
status = str(row["status"])
|
||||
productivity = _PRODUCTIVITY_LAYER.get(mapped_layer or "")
|
||||
records.append(
|
||||
LegacyMemoryInventory(
|
||||
legacy_memory_id=legacy_id,
|
||||
@@ -265,8 +225,6 @@ def read_legacy_memory_inventory(database_path: Path) -> tuple[LegacyMemoryInven
|
||||
tags=tags,
|
||||
supersedes=tuple(supersedes.get(legacy_id, ())),
|
||||
superseded_by=tuple(superseded_by.get(legacy_id, ())),
|
||||
productivity_kind=productivity[0] if productivity else None,
|
||||
productivity_variant=productivity[1] if productivity else None,
|
||||
)
|
||||
)
|
||||
return tuple(records)
|
||||
@@ -281,116 +239,43 @@ def _parse_datetime(value: object) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def read_legacy_raw_inventory(
|
||||
database_path: Path,
|
||||
*,
|
||||
cutoff: datetime,
|
||||
) -> tuple[LegacyRawInventory, ...]:
|
||||
"""Rebuild recent legacy messages into one user/assistant raw per turn."""
|
||||
if cutoff.tzinfo is None:
|
||||
cutoff = cutoff.replace(tzinfo=UTC)
|
||||
def read_legacy_raw_inventory(database_path: Path) -> tuple[LegacyRawInventory, ...]:
|
||||
with main_database(database_path).read(row_factory=sqlite3.Row) as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT evidence_id,user_id,source_id,workspace,project,
|
||||
conversation_id,message_id,source_ordinal,content_kind,
|
||||
role,written_by,created_at,imported_at,verbatim,content_hash,revision
|
||||
FROM evidence
|
||||
WHERE content_kind='raw_conversation'
|
||||
AND (
|
||||
(role='user' AND written_by='person')
|
||||
OR role='assistant'
|
||||
)
|
||||
ORDER BY source_id,conversation_id,workspace,
|
||||
COALESCE(created_at,imported_at),
|
||||
COALESCE(ingest_seq,rowid),evidence_id
|
||||
FROM evidence ORDER BY COALESCE(ingest_seq,rowid),evidence_id
|
||||
"""
|
||||
).fetchall()
|
||||
turns: list[LegacyRawInventory] = []
|
||||
current_key: tuple[str, str, str, str] | None = None
|
||||
current: list[sqlite3.Row] = []
|
||||
|
||||
def finish() -> None:
|
||||
if not current or str(current[0]["role"]) != "user":
|
||||
return
|
||||
assistants = [row for row in current[1:] if str(row["role"]) == "assistant"]
|
||||
if not assistants:
|
||||
return
|
||||
rows_for_turn = [current[0], *assistants]
|
||||
completed_at = _parse_datetime(
|
||||
assistants[-1]["created_at"] or assistants[-1]["imported_at"]
|
||||
return tuple(
|
||||
LegacyRawInventory(
|
||||
evidence_id=str(row["evidence_id"]),
|
||||
user_id=str(row["user_id"]),
|
||||
agent_id="one-memory",
|
||||
session_id=str(row["conversation_id"]),
|
||||
source=str(row["source_id"]),
|
||||
content=str(row["verbatim"]),
|
||||
content_hash=str(row["content_hash"]),
|
||||
memory_at=_parse_datetime(row["created_at"] or row["imported_at"]),
|
||||
metadata={
|
||||
key: row[key]
|
||||
for key in (
|
||||
"workspace",
|
||||
"project",
|
||||
"message_id",
|
||||
"source_ordinal",
|
||||
"content_kind",
|
||||
"role",
|
||||
"written_by",
|
||||
"revision",
|
||||
)
|
||||
if row[key] is not None
|
||||
},
|
||||
)
|
||||
if completed_at is not None and completed_at.tzinfo is None:
|
||||
completed_at = completed_at.replace(tzinfo=UTC)
|
||||
if completed_at is None or completed_at.astimezone(UTC) < cutoff.astimezone(UTC):
|
||||
return
|
||||
evidence_ids = [str(row["evidence_id"]) for row in rows_for_turn]
|
||||
source = str(current[0]["source_id"])
|
||||
session_id = str(current[0]["conversation_id"])
|
||||
digest = hashlib.sha256(
|
||||
"\0".join((source, session_id, *evidence_ids)).encode()
|
||||
).hexdigest()
|
||||
raw_user_text = str(rows_for_turn[0]["verbatim"] or "")
|
||||
user_text = sanitize_model_input(raw_user_text)[:_RAW_USER_MAX_CHARACTERS]
|
||||
content_parts = [f"[user]: {user_text}"]
|
||||
truncated = len(raw_user_text) > len(user_text)
|
||||
for row in assistants:
|
||||
sanitized = sanitize_model_input(str(row["verbatim"] or ""))
|
||||
text = sanitized
|
||||
prefix = "[assistant]: "
|
||||
remaining = _RAW_TURN_MAX_CHARACTERS - len("\n".join(content_parts)) - 1
|
||||
if remaining <= len(prefix):
|
||||
truncated = True
|
||||
break
|
||||
available = remaining - len(prefix)
|
||||
if len(sanitized) > available:
|
||||
text = text[:available]
|
||||
truncated = True
|
||||
content_parts.append(f"{prefix}{text}")
|
||||
if len(text) < len(sanitized):
|
||||
break
|
||||
content = "\n".join(content_parts)
|
||||
if not content:
|
||||
return
|
||||
turns.append(
|
||||
LegacyRawInventory(
|
||||
evidence_id=f"turn:{digest}",
|
||||
user_id=str(current[0]["user_id"]),
|
||||
agent_id="all-apps",
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
content=content,
|
||||
content_hash=content_hash(content),
|
||||
memory_at=_parse_datetime(
|
||||
current[0]["created_at"] or current[0]["imported_at"]
|
||||
),
|
||||
metadata={
|
||||
"evidence_ids": evidence_ids,
|
||||
"workspace": current[0]["workspace"],
|
||||
"project": current[0]["project"],
|
||||
"input_kind": "legacy_turn",
|
||||
"truncated": truncated,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
key = (
|
||||
str(row["source_id"]),
|
||||
str(row["conversation_id"]),
|
||||
str(row["user_id"]),
|
||||
str(row["workspace"] or ""),
|
||||
)
|
||||
if key != current_key:
|
||||
finish()
|
||||
current_key, current = key, []
|
||||
if str(row["role"]) == "user":
|
||||
finish()
|
||||
current = [row]
|
||||
elif current:
|
||||
current.append(row)
|
||||
finish()
|
||||
return tuple(turns)
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
def classify_memory_inventory(
|
||||
@@ -398,39 +283,13 @@ def classify_memory_inventory(
|
||||
sdk: tuple[SdkMemoryInventory, ...],
|
||||
) -> tuple[MemoryMigrationDecision, ...]:
|
||||
"""Classify every legacy and SDK memory; no item may silently disappear."""
|
||||
sdk_by_id: dict[str, list[SdkMemoryInventory]] = {}
|
||||
for sdk_item in sdk:
|
||||
sdk_by_id.setdefault(sdk_item.memory_id, []).append(sdk_item)
|
||||
claimed_sdk_items: set[tuple[str, str | None, str | None]] = set()
|
||||
sdk_by_id = {item.memory_id: item for item in sdk}
|
||||
claimed_sdk_ids: set[str] = set()
|
||||
decisions: list[MemoryMigrationDecision] = []
|
||||
for item in legacy:
|
||||
candidates = [
|
||||
candidate
|
||||
for candidate in sdk_by_id.get(item.canonical_memory_id, ())
|
||||
if (
|
||||
candidate.productivity_kind == item.productivity_kind
|
||||
and (
|
||||
item.productivity_kind is None
|
||||
or item.productivity_variant is None
|
||||
or candidate.productivity_variant == item.productivity_variant
|
||||
)
|
||||
)
|
||||
]
|
||||
if len(candidates) > 1:
|
||||
claimed_sdk_items.update(_sdk_inventory_key(candidate) for candidate in candidates)
|
||||
decisions.append(
|
||||
MemoryMigrationDecision(
|
||||
item_key=f"memory:{item.legacy_memory_id}",
|
||||
action="quarantine",
|
||||
legacy_memory_id=item.legacy_memory_id,
|
||||
canonical_memory_id=item.canonical_memory_id,
|
||||
reason="sdk_memory_id_ambiguous",
|
||||
)
|
||||
)
|
||||
continue
|
||||
current = candidates[0] if candidates else None
|
||||
current = sdk_by_id.get(item.canonical_memory_id)
|
||||
if current is not None:
|
||||
claimed_sdk_items.add(_sdk_inventory_key(current))
|
||||
claimed_sdk_ids.add(current.memory_id)
|
||||
if item.status == "deleted":
|
||||
decisions.append(
|
||||
MemoryMigrationDecision(
|
||||
@@ -454,17 +313,6 @@ def classify_memory_inventory(
|
||||
)
|
||||
continue
|
||||
if current is None:
|
||||
if item.productivity_kind is not None:
|
||||
decisions.append(
|
||||
MemoryMigrationDecision(
|
||||
item_key=f"memory:{item.legacy_memory_id}",
|
||||
action="quarantine",
|
||||
legacy_memory_id=item.legacy_memory_id,
|
||||
canonical_memory_id=item.canonical_memory_id,
|
||||
reason="mapped_productivity_memory_missing",
|
||||
)
|
||||
)
|
||||
continue
|
||||
decisions.append(
|
||||
MemoryMigrationDecision(
|
||||
item_key=f"memory:{item.legacy_memory_id}",
|
||||
@@ -475,19 +323,7 @@ def classify_memory_inventory(
|
||||
)
|
||||
)
|
||||
continue
|
||||
if item.productivity_kind is not None and (
|
||||
current.user_id == item.user_id
|
||||
and current.productivity_kind == item.productivity_kind
|
||||
and (
|
||||
item.productivity_variant is None
|
||||
or current.productivity_variant == item.productivity_variant
|
||||
)
|
||||
):
|
||||
action = (
|
||||
"matched" if item.legacy_memory_id == item.canonical_memory_id else "record_alias"
|
||||
)
|
||||
reason = "existing_productivity_identity"
|
||||
elif (
|
||||
if (
|
||||
current.content_hash == item.content_hash
|
||||
and current.status == item.status
|
||||
and current.user_id == item.user_id
|
||||
@@ -510,10 +346,10 @@ def classify_memory_inventory(
|
||||
)
|
||||
|
||||
for sdk_item in sdk:
|
||||
if _sdk_inventory_key(sdk_item) not in claimed_sdk_items:
|
||||
if sdk_item.memory_id not in claimed_sdk_ids:
|
||||
decisions.append(
|
||||
MemoryMigrationDecision(
|
||||
item_key=_sdk_item_key(sdk_item),
|
||||
item_key=f"sdk:{sdk_item.memory_id}",
|
||||
action="adopt",
|
||||
legacy_memory_id=None,
|
||||
canonical_memory_id=sdk_item.memory_id,
|
||||
@@ -523,33 +359,13 @@ def classify_memory_inventory(
|
||||
return tuple(decisions)
|
||||
|
||||
|
||||
def _sdk_inventory_key(
|
||||
item: SdkMemoryInventory,
|
||||
) -> tuple[str, str | None, str | None]:
|
||||
return (
|
||||
item.memory_id,
|
||||
item.productivity_kind,
|
||||
item.productivity_variant,
|
||||
)
|
||||
|
||||
|
||||
def _sdk_item_key(item: SdkMemoryInventory) -> str:
|
||||
namespace = (
|
||||
f"{item.productivity_variant}:{item.productivity_kind}"
|
||||
if item.productivity_kind is not None
|
||||
else "chat"
|
||||
)
|
||||
return f"sdk:{namespace}:{item.memory_id}"
|
||||
|
||||
|
||||
async def read_sdk_memory_inventory(
|
||||
client: SdkMigrationClient,
|
||||
*,
|
||||
legacy: tuple[LegacyMemoryInventory, ...] = (),
|
||||
user_id: str = "local",
|
||||
page_size: int = 500,
|
||||
) -> tuple[SdkMemoryInventory, ...]:
|
||||
records: list[SdkMemoryInventory] = []
|
||||
records: dict[str, SdkMemoryInventory] = {}
|
||||
offset = 0
|
||||
while True:
|
||||
page = await client.list_memories(
|
||||
@@ -560,45 +376,18 @@ async def read_sdk_memory_inventory(
|
||||
order="asc",
|
||||
)
|
||||
for memory in page.memories:
|
||||
records.append(
|
||||
SdkMemoryInventory(
|
||||
memory_id=memory.memory_id,
|
||||
user_id=memory.user_id,
|
||||
agent_id=memory.agent_id,
|
||||
content_hash=content_hash(memory.content),
|
||||
status=memory.status,
|
||||
)
|
||||
records[memory.memory_id] = SdkMemoryInventory(
|
||||
memory_id=memory.memory_id,
|
||||
user_id=memory.user_id,
|
||||
agent_id=memory.agent_id,
|
||||
content_hash=content_hash(memory.content),
|
||||
status=memory.status,
|
||||
)
|
||||
consumed = len(page.memories)
|
||||
offset += consumed
|
||||
if consumed == 0 or offset >= page.total:
|
||||
break
|
||||
del legacy
|
||||
for memory in await client.list_productivity_memories():
|
||||
try:
|
||||
records.append(
|
||||
SdkMemoryInventory(
|
||||
memory_id=str(memory["memory_id"]),
|
||||
user_id=str(memory["user_id"]),
|
||||
agent_id=str(memory["agent_id"]),
|
||||
content_hash=content_hash(str(memory["content"])),
|
||||
status=str(memory["status"]),
|
||||
productivity_kind=str(memory["kind"]),
|
||||
productivity_variant=str(memory["variant"]),
|
||||
)
|
||||
)
|
||||
except KeyError as error:
|
||||
raise RuntimeError("invalid productivity inventory row") from error
|
||||
return tuple(
|
||||
sorted(
|
||||
records,
|
||||
key=lambda item: (
|
||||
item.memory_id,
|
||||
item.productivity_kind or "",
|
||||
item.productivity_variant or "",
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(records[key] for key in sorted(records))
|
||||
|
||||
|
||||
def record_memory_migration_plan(
|
||||
@@ -610,12 +399,12 @@ def record_memory_migration_plan(
|
||||
) -> tuple[MemoryMigrationDecision, ...]:
|
||||
decisions = classify_memory_inventory(legacy, sdk)
|
||||
legacy_by_id = {item.legacy_memory_id: item for item in legacy}
|
||||
sdk_by_item_key = {_sdk_item_key(item): item for item in sdk}
|
||||
sdk_by_id = {item.memory_id: item for item in sdk}
|
||||
for decision in decisions:
|
||||
source_hash = (
|
||||
legacy_by_id[decision.legacy_memory_id].content_hash
|
||||
if decision.legacy_memory_id is not None
|
||||
else sdk_by_item_key[decision.item_key].content_hash
|
||||
else sdk_by_id[decision.canonical_memory_id].content_hash
|
||||
)
|
||||
operation_key = hashlib.sha256(
|
||||
f"{migration_id}:{decision.item_key}:{decision.action}:{source_hash}".encode()
|
||||
@@ -668,15 +457,7 @@ async def execute_memory_migration_plan(
|
||||
continue
|
||||
if decision.action in {"delete", "verified_deleted"}:
|
||||
if decision.action == "delete":
|
||||
if item is not None and item.productivity_kind is not None:
|
||||
await client.delete_productivity_memory(
|
||||
decision.canonical_memory_id,
|
||||
kind=item.productivity_kind,
|
||||
variant=item.productivity_variant,
|
||||
user_id=item.user_id,
|
||||
)
|
||||
else:
|
||||
await client.delete(decision.canonical_memory_id)
|
||||
await client.delete(decision.canonical_memory_id)
|
||||
store.settle_item(
|
||||
migration_id=migration_id,
|
||||
item_key=decision.item_key,
|
||||
@@ -814,10 +595,10 @@ def classify_raw_inventory(
|
||||
decisions.append(
|
||||
RawMigrationDecision(
|
||||
item_key=f"sdk-raw:{raw.raw_id}",
|
||||
action="delete",
|
||||
action="adopt",
|
||||
evidence_id=None,
|
||||
raw_id=raw.raw_id,
|
||||
reason="outside_canonical_three_day_turns",
|
||||
reason="sdk_only",
|
||||
)
|
||||
)
|
||||
return tuple(decisions)
|
||||
@@ -863,12 +644,11 @@ async def execute_raw_migration_plan(
|
||||
) -> None:
|
||||
legacy_by_id = {item.evidence_id: item for item in legacy}
|
||||
for decision in decisions:
|
||||
if decision.action == "delete":
|
||||
await client.delete_raw(decision.raw_id)
|
||||
if decision.action == "adopt":
|
||||
store.settle_item(
|
||||
migration_id=migration_id,
|
||||
item_key=decision.item_key,
|
||||
state="deleted",
|
||||
state="adopted",
|
||||
target_memory_id=decision.raw_id,
|
||||
)
|
||||
continue
|
||||
@@ -897,15 +677,8 @@ async def execute_raw_migration_plan(
|
||||
target_memory_id=decision.raw_id,
|
||||
target_hash=item.content_hash,
|
||||
)
|
||||
evidence_ids = item.metadata.get("evidence_ids")
|
||||
for evidence_id in (
|
||||
evidence_ids
|
||||
if isinstance(evidence_ids, list)
|
||||
else [item.evidence_id]
|
||||
):
|
||||
if isinstance(evidence_id, str) and evidence_id:
|
||||
store.record_raw_alias(
|
||||
migration_id=migration_id,
|
||||
legacy_evidence_id=evidence_id,
|
||||
canonical_raw_id=decision.raw_id,
|
||||
)
|
||||
store.record_raw_alias(
|
||||
migration_id=migration_id,
|
||||
legacy_evidence_id=item.evidence_id,
|
||||
canonical_raw_id=decision.raw_id,
|
||||
)
|
||||
@@ -9,7 +9,6 @@ import sqlite3
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from one_memory.database_access import main_database
|
||||
@@ -407,27 +406,14 @@ async def run_sdk_data_plane_backfill(
|
||||
store.set_phase(migration_id, phase="inventory", state="running")
|
||||
|
||||
legacy_memories = read_legacy_memory_inventory(database_path)
|
||||
sdk_memories = await read_sdk_memory_inventory(client, legacy=legacy_memories)
|
||||
sdk_memories = await read_sdk_memory_inventory(client)
|
||||
memory_decisions = record_memory_migration_plan(
|
||||
store,
|
||||
migration_id=migration_id,
|
||||
legacy=legacy_memories,
|
||||
sdk=sdk_memories,
|
||||
)
|
||||
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
|
||||
started = database.execute(
|
||||
"SELECT created_at FROM sdk_data_plane_migrations WHERE migration_id=?",
|
||||
(migration_id,),
|
||||
).fetchone()
|
||||
if started is None:
|
||||
raise DataPlaneMigrationError("sdk_data_plane_migration_start_missing")
|
||||
started_at = datetime.fromisoformat(str(started["created_at"]))
|
||||
if started_at.tzinfo is None:
|
||||
started_at = started_at.replace(tzinfo=UTC)
|
||||
legacy_raw = read_legacy_raw_inventory(
|
||||
database_path,
|
||||
cutoff=started_at.astimezone(UTC) - timedelta(days=3),
|
||||
)
|
||||
legacy_raw = read_legacy_raw_inventory(database_path)
|
||||
sdk_raw = await read_sdk_raw_inventory(client)
|
||||
raw_decisions = record_raw_migration_plan(
|
||||
store,
|
||||
|
||||
@@ -14,14 +14,13 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||
EXPECTED_TEST_FILE_COUNT = 323
|
||||
EXPECTED_TEST_FILES_SHA256 = "10a2327bd047a281511980f5b558c1ea64d857f0fff550e4bd96cb53800205ba"
|
||||
EXPECTED_TEST_FILE_COUNT = 322
|
||||
EXPECTED_TEST_FILES_SHA256 = "d242783adb4015ebdbb5c099f3b5aa88a99c828ca6fddc8ca8d6c74c56e48945"
|
||||
|
||||
# Pure transformations, validation models, parity checks and state machines.
|
||||
# This list is intentionally small: uncertain files default to contract.
|
||||
UNIT_FILES = frozenset(
|
||||
{
|
||||
"test_cli_migration_error_codes.py",
|
||||
"test_context_v2_contract_parity.py",
|
||||
"test_daemon_contract.py",
|
||||
"test_diagnostic_payload_keys.py",
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""The startup dialog can only act on the code it is handed.
|
||||
|
||||
`sdk_migration_backfill_failed` covered a full disk, a missing model profile and
|
||||
a Coding classification gap alike, so every one of them reached the person as
|
||||
the same unactionable sentence. These tests pin the names that replace it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from one_memory.cli import cutover_failure_code, migration_failure_code
|
||||
|
||||
|
||||
class _Coded(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def test_running_out_of_disk_is_named_however_deep_it_is_wrapped() -> None:
|
||||
error = OSError(errno.ENOSPC, "No space left on device")
|
||||
wrapped: BaseException = error
|
||||
for _ in range(3):
|
||||
try:
|
||||
raise RuntimeError("wrapper") from wrapped
|
||||
except RuntimeError as caught:
|
||||
wrapped = caught
|
||||
|
||||
assert migration_failure_code(wrapped) == "sdk_migration_disk_space_insufficient"
|
||||
# The explicit context chain counts too: `raise ... from None` discards the
|
||||
# cause but Python still keeps the original as __context__.
|
||||
try:
|
||||
try:
|
||||
raise OSError(errno.ENOSPC, "No space left on device")
|
||||
except OSError:
|
||||
raise RuntimeError("sdk_snapshot_failed") from None
|
||||
except RuntimeError as caught:
|
||||
assert migration_failure_code(caught) == "sdk_migration_disk_space_insufficient"
|
||||
|
||||
|
||||
def test_quota_exhaustion_counts_as_no_space(tmp_path: Path) -> None:
|
||||
quota = getattr(errno, "EDQUOT", errno.ENOSPC)
|
||||
assert (
|
||||
migration_failure_code(OSError(quota, "Disk quota exceeded"))
|
||||
== "sdk_migration_disk_space_insufficient"
|
||||
)
|
||||
|
||||
|
||||
def test_a_coding_inventory_gap_is_not_reported_as_a_generic_failure() -> None:
|
||||
assert (
|
||||
migration_failure_code(_Coded("hy_sdk_productivity_variant_invalid"))
|
||||
== "sdk_migration_coding_inventory_failed"
|
||||
)
|
||||
|
||||
|
||||
def test_the_legacy_working_fact_failure_keeps_its_own_name() -> None:
|
||||
error = _Coded("hy_sdk_memory_import_failed")
|
||||
error.__cause__ = ValueError("Invalid memory layer: working_fact")
|
||||
|
||||
assert migration_failure_code(error) == "sdk_migration_working_fact_conflict"
|
||||
|
||||
|
||||
def test_migration_state_codes_pass_through_because_they_are_already_actionable() -> None:
|
||||
assert (
|
||||
migration_failure_code(_Coded("sdk_data_plane_migration_item_conflict"))
|
||||
== "sdk_data_plane_migration_item_conflict"
|
||||
)
|
||||
assert (
|
||||
migration_failure_code(_Coded("sdk_migration_staging_invalid"))
|
||||
== "sdk_migration_staging_invalid"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_failures_keep_their_message_out_of_the_code() -> None:
|
||||
error = RuntimeError("/Users/someone/private/path blew up")
|
||||
assert migration_failure_code(error) == "sdk_migration_backfill_failed"
|
||||
assert (
|
||||
migration_failure_code(error, fallback="sdk_migration_preflight_failed")
|
||||
== "sdk_migration_preflight_failed"
|
||||
)
|
||||
|
||||
|
||||
def test_a_non_code_attribute_never_becomes_a_code() -> None:
|
||||
class Odd(RuntimeError):
|
||||
code = 17
|
||||
|
||||
assert migration_failure_code(Odd("x")) == "sdk_migration_backfill_failed"
|
||||
|
||||
|
||||
def test_the_chain_survives_a_cycle() -> None:
|
||||
first = RuntimeError("first")
|
||||
second = RuntimeError("second")
|
||||
first.__cause__ = second
|
||||
second.__cause__ = first
|
||||
|
||||
assert migration_failure_code(first) == "sdk_migration_backfill_failed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
"sdk_migration_disk_space_insufficient",
|
||||
"sdk_migration_working_fact_conflict",
|
||||
"sdk_migration_coding_inventory_failed",
|
||||
"sdk_migration_work_pending",
|
||||
],
|
||||
)
|
||||
def test_the_codes_the_desktop_translates_stay_in_sync(code: str) -> None:
|
||||
"""Desktop maps these exact names to sentences; renaming one blinds it."""
|
||||
desktop = Path(__file__).resolve().parents[2] / "desktop" / "src" / "stable-runtime.ts"
|
||||
assert desktop.is_file(), desktop
|
||||
assert f'"{code}"' in desktop.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_cutover_work_counts_do_not_become_the_dialog_text() -> None:
|
||||
"""The blocker code carries a JSON body; the person gets one name."""
|
||||
blocked = _Coded('sdk_data_plane_cutover_work_pending:{"memory_tasks":591}')
|
||||
|
||||
assert cutover_failure_code(blocked) == "sdk_migration_work_pending"
|
||||
assert (
|
||||
cutover_failure_code(_Coded("sdk_data_plane_cutover_missing"))
|
||||
== "sdk_data_plane_cutover_missing"
|
||||
)
|
||||
assert (
|
||||
cutover_failure_code(RuntimeError("/Users/someone/private"))
|
||||
== "sdk_migration_cutover_failed"
|
||||
)
|
||||
@@ -1,15 +1,10 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import sqlite3
|
||||
from types import SimpleNamespace
|
||||
|
||||
from one_memory import cli
|
||||
from one_memory.config import Settings
|
||||
from one_memory.memory_server.migration_runner import SqliteMigrationStore, migrate
|
||||
from one_memory.memory_server.migrations import MIGRATIONS
|
||||
from one_memory.runtime_client import RuntimeStatus
|
||||
from one_memory.sdk_data_plane_migration import SdkDataPlaneMigrationStore
|
||||
|
||||
|
||||
def test_restore_requires_a_source(tmp_path, monkeypatch) -> None:
|
||||
@@ -368,7 +363,7 @@ def test_sdk_cutover_is_offline_confirmed_and_returns_marker(tmp_path, monkeypat
|
||||
lambda **_kwargs: SimpleNamespace(
|
||||
migration_id="migration-1",
|
||||
backup_bundle_id="bundle-1",
|
||||
sdk_version="2.0.5",
|
||||
sdk_version="2.0.5.dev3",
|
||||
completed_at="2026-10-21T00:00:00Z",
|
||||
),
|
||||
)
|
||||
@@ -398,7 +393,7 @@ def test_sdk_cutover_is_offline_confirmed_and_returns_marker(tmp_path, monkeypat
|
||||
assert code == 0
|
||||
document = output.getvalue().replace(" ", "")
|
||||
assert '"state":"cutover"' in document
|
||||
assert '"sdk_version":"2.0.5"' in document
|
||||
assert '"sdk_version":"2.0.5.dev3"' in document
|
||||
|
||||
|
||||
def test_sdk_cutover_never_runs_while_runtime_is_live(tmp_path, monkeypatch) -> None:
|
||||
@@ -441,41 +436,3 @@ def test_sdk_migration_status_distinguishes_empty_and_legacy_data(tmp_path, monk
|
||||
assert cli.run(["sdk-migration-status", "--json"], output=required) == 0
|
||||
assert '"state": "required"' in required.getvalue()
|
||||
assert '"raw": 1' in required.getvalue()
|
||||
|
||||
|
||||
def test_sdk_migration_status_exposes_a_resumable_active_identity(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"load_settings",
|
||||
lambda: Settings(_env_file=None, data_dir=tmp_path),
|
||||
)
|
||||
database = tmp_path / "one-memory.sqlite3"
|
||||
asyncio.run(migrate(SqliteMigrationStore(database), MIGRATIONS))
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO evidence ("
|
||||
"evidence_id,idempotency_key,user_id,source_id,conversation_id,"
|
||||
"content_kind,role,written_by,verbatim,content_hash,revision,"
|
||||
"imported_at,updated_at"
|
||||
") VALUES ('e1','k','local','cursor','s','raw_conversation',"
|
||||
"'user','person','hello','hash',1,'now','now')"
|
||||
)
|
||||
SdkDataPlaneMigrationStore(database).start(
|
||||
migration_id="sdk-authority-v2-0123456789abcdef",
|
||||
source_runtime_build="17",
|
||||
target_sdk_version="2.0.5",
|
||||
source_schema_version=MIGRATIONS[-1].version,
|
||||
namespace_fingerprint="namespace",
|
||||
backend_fingerprint="backend",
|
||||
backup_bundle_id=(
|
||||
"sdk-authority-v2-0123456789abcdef-pre-cutover"
|
||||
),
|
||||
)
|
||||
|
||||
output = io.StringIO()
|
||||
assert cli.run(["sdk-migration-status", "--json"], output=output) == 0
|
||||
document = json.loads(output.getvalue())
|
||||
assert document["migration_id"] == "sdk-authority-v2-0123456789abcdef"
|
||||
assert document["bundle_id"] == (
|
||||
"sdk-authority-v2-0123456789abcdef-pre-cutover"
|
||||
)
|
||||
@@ -18,13 +18,11 @@ from one_memory.diagnostic_log import (
|
||||
diagnostic_exception,
|
||||
diagnostics_enabled,
|
||||
diagnostics_requested,
|
||||
read_trace_events,
|
||||
record_database_error,
|
||||
reset_diagnostics,
|
||||
reset_trace_id,
|
||||
resource_snapshot,
|
||||
)
|
||||
from one_memory.exception_reporting import report_exception
|
||||
|
||||
|
||||
def test_debug_events_are_collected_only_at_debug_level(tmp_path, monkeypatch) -> None:
|
||||
@@ -107,31 +105,7 @@ def test_structured_log_is_private_correlated_and_owner_only(tmp_path):
|
||||
assert log.path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_arbitrary_valid_detail_keys_and_scalars_are_recorded(tmp_path):
|
||||
log = DiagnosticLog(tmp_path)
|
||||
details = {
|
||||
"Custom.metric-v1": "ready",
|
||||
"noneValue": None,
|
||||
"boolValue": False,
|
||||
"intValue": 7,
|
||||
"floatValue": 1.5,
|
||||
}
|
||||
log.write(
|
||||
level="info",
|
||||
component="core.lifecycle",
|
||||
event="core.started",
|
||||
outcome="succeeded",
|
||||
details=details,
|
||||
)
|
||||
assert log.flush()
|
||||
assert json.loads(log.path.read_text())["details"] == details
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
["__proto__", "prototype", "constructor", "_private", "9startsWithDigit", "a" * 65],
|
||||
)
|
||||
def test_dangerous_or_malformed_detail_keys_are_rejected(tmp_path, key):
|
||||
def test_arbitrary_detail_and_absolute_stack_are_rejected(tmp_path):
|
||||
log = DiagnosticLog(tmp_path)
|
||||
with pytest.raises(ValueError, match="diagnostic_detail_key_invalid"):
|
||||
log.write(
|
||||
@@ -139,76 +113,8 @@ def test_dangerous_or_malformed_detail_keys_are_rejected(tmp_path, key):
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
details={key: "value"},
|
||||
details={"prompt": "private"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [{}, [], object()])
|
||||
def test_complex_detail_values_are_rejected(tmp_path, value):
|
||||
log = DiagnosticLog(tmp_path)
|
||||
with pytest.raises(ValueError, match="diagnostic_detail_value_invalid"):
|
||||
log.write(
|
||||
level="error",
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
details={"payload": value},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_non_finite_detail_values_are_rejected(tmp_path, value):
|
||||
log = DiagnosticLog(tmp_path)
|
||||
with pytest.raises(ValueError, match="diagnostic_detail_value_invalid"):
|
||||
log.write(
|
||||
level="error",
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
details={"metric": value},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [2**53, -(2**53), float(2**53), -float(2**53)])
|
||||
def test_numeric_detail_values_outside_json_safe_range_are_rejected(tmp_path, value):
|
||||
log = DiagnosticLog(tmp_path)
|
||||
with pytest.raises(ValueError, match="diagnostic_detail_value_invalid"):
|
||||
log.write(
|
||||
level="error",
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
details={"count": value},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "nul\0byte", "carriage\rreturn", "line\nfeed", "x" * 257])
|
||||
def test_invalid_detail_strings_are_rejected(tmp_path, value):
|
||||
log = DiagnosticLog(tmp_path)
|
||||
with pytest.raises(ValueError, match="diagnostic_detail_value_invalid"):
|
||||
log.write(
|
||||
level="error",
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
details={"message": value},
|
||||
)
|
||||
|
||||
|
||||
def test_more_than_32_detail_keys_are_rejected(tmp_path):
|
||||
log = DiagnosticLog(tmp_path)
|
||||
with pytest.raises(ValueError, match="diagnostic_detail_count_invalid"):
|
||||
log.write(
|
||||
level="error",
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
details={f"key{index}": index for index in range(33)},
|
||||
)
|
||||
|
||||
|
||||
def test_absolute_or_credential_bearing_stack_is_rejected(tmp_path):
|
||||
log = DiagnosticLog(tmp_path)
|
||||
with pytest.raises(ValueError, match="diagnostic_stack_invalid"):
|
||||
log.write(
|
||||
level="error",
|
||||
@@ -217,90 +123,16 @@ def test_absolute_or_credential_bearing_stack_is_rejected(tmp_path):
|
||||
outcome="failed",
|
||||
safe_stack=("/Users/me/private.py:1",),
|
||||
)
|
||||
with pytest.raises(ValueError, match="diagnostic_stack_invalid"):
|
||||
log.write(
|
||||
level="error",
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
safe_stack=("one_memory/sk-live-secret-token.py:1:call",),
|
||||
)
|
||||
|
||||
|
||||
def test_safe_stack_uses_strict_shared_grammar_and_sixteen_line_limit(tmp_path):
|
||||
log = DiagnosticLog(tmp_path)
|
||||
for line in (
|
||||
"arbitrary stack text",
|
||||
"thread=worker|threading.py:12:wait",
|
||||
"FILE:///Users/me/private.py:1:call",
|
||||
"one_memory/worker.py:9:call /Users/private",
|
||||
"one_memory/GOCSPX-privateCredential.py:9:call",
|
||||
"packages/ASIA1234567890ABCDEF/file.ts:1:1",
|
||||
"packages/Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8/file.ts:1:1",
|
||||
):
|
||||
with pytest.raises(ValueError, match="diagnostic_stack_invalid"):
|
||||
log.write(
|
||||
level="error",
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
safe_stack=(line,),
|
||||
)
|
||||
def test_a_rejected_payload_does_not_break_the_caller(tmp_path, monkeypatch) -> None:
|
||||
"""Rejection protects the log; it must not become the caller's outage.
|
||||
|
||||
accepted = (
|
||||
"apps/desktop/src/main.ts:80:1",
|
||||
"one_memory/diagnostic_log.py:895:_safe_stack_line",
|
||||
"threading.py:1045:_bootstrap_inner",
|
||||
"thread|threading.py:1045:_bootstrap_inner > one_memory/worker.py:9:call",
|
||||
)
|
||||
log.write(
|
||||
level="error",
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
safe_stack=accepted,
|
||||
)
|
||||
with pytest.raises(ValueError, match="diagnostic_stack_invalid"):
|
||||
log.write(
|
||||
level="error",
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
safe_stack=tuple(f"one_memory/worker.py:{index + 1}:call" for index in range(17)),
|
||||
)
|
||||
assert log.flush()
|
||||
assert json.loads(log.path.read_text())["safe_stack"] == list(accepted)
|
||||
|
||||
|
||||
def test_invalid_level_is_strict_but_diagnostic_event_remains_fail_open(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
log = DiagnosticLog(tmp_path)
|
||||
with pytest.raises(ValueError, match="diagnostic_level_invalid"):
|
||||
log.write(
|
||||
level="fatal", # type: ignore[arg-type]
|
||||
component="core.lifecycle",
|
||||
event="core.failed",
|
||||
outcome="failed",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1")
|
||||
receipt = configure_diagnostics(tmp_path / "fail-open")
|
||||
try:
|
||||
diagnostic_event(
|
||||
level="fatal",
|
||||
component="core.lifecycle",
|
||||
event="core.failed",
|
||||
outcome="failed",
|
||||
)
|
||||
finally:
|
||||
reset_diagnostics()
|
||||
assert not receipt.path.exists()
|
||||
|
||||
|
||||
def test_a_rejected_payload_does_not_break_the_caller_or_later_events(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
Two of our own nodes shipped with unlisted `details` keys. Both sat on a
|
||||
request path, so the rejection surfaced as a 500 to the person using the
|
||||
app — including the sedimentation switch, whose every request failed
|
||||
because logging the toggle raised before the endpoint could answer.
|
||||
"""
|
||||
monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1")
|
||||
receipt = configure_diagnostics(tmp_path)
|
||||
try:
|
||||
@@ -309,122 +141,20 @@ def test_a_rejected_payload_does_not_break_the_caller_or_later_events(
|
||||
component="memory.sedimentation",
|
||||
event="memory_sedimentation.toggled",
|
||||
outcome="succeeded",
|
||||
details={"payload": ["not", "scalar"]},
|
||||
)
|
||||
diagnostic_event(
|
||||
level="info",
|
||||
component="memory.sedimentation",
|
||||
event="memory_sedimentation.toggled",
|
||||
outcome="succeeded",
|
||||
details={"custom.metric": 1},
|
||||
details={"prompt": "private"},
|
||||
)
|
||||
finally:
|
||||
reset_diagnostics()
|
||||
events = [json.loads(line) for line in receipt.path.read_text().splitlines()]
|
||||
assert [event["details"] for event in events] == [{"custom.metric": 1}]
|
||||
|
||||
|
||||
def test_trace_projection_does_not_expose_arbitrary_local_detail_keys(tmp_path):
|
||||
log = DiagnosticLog(tmp_path)
|
||||
log.write(
|
||||
level="info",
|
||||
component="core.api",
|
||||
event="memory.request.completed",
|
||||
outcome="succeeded",
|
||||
request_id="request-1",
|
||||
details={
|
||||
"phase": "recall",
|
||||
"reason": "/Users/private/query.txt",
|
||||
"status": "sk-live-secret-token",
|
||||
"custom.private": "local-only",
|
||||
},
|
||||
)
|
||||
assert log.flush()
|
||||
events = read_trace_events(tmp_path, request_id="request-1")
|
||||
assert events[0]["details"] == {"phase": "recall"}
|
||||
|
||||
|
||||
def test_trace_projection_rejects_symlinked_directory_and_redacts_top_level_secrets(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
request_id = "request-1"
|
||||
logs = tmp_path / "logs"
|
||||
logs.mkdir()
|
||||
event = {
|
||||
"timestamp": "2026-10-23T01:00:00Z",
|
||||
"request_id": request_id,
|
||||
"component": "secret.token",
|
||||
"event": "credential.failed",
|
||||
"outcome": "Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
|
||||
"error_code": "GOCSPX-clientSecret_A1",
|
||||
"error_type": "ASIA1234567890ABCDEF",
|
||||
"details": {
|
||||
"phase": "recall",
|
||||
"reason": "x_ASIA1234567890ABCDEF",
|
||||
"mode": "x_GOCSPX-clientSecret_A1",
|
||||
"status": "Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
|
||||
"failure_site": "AIzaSyExampleCredential1234567890",
|
||||
"count": 1,
|
||||
},
|
||||
}
|
||||
(logs / "diagnostic-core.jsonl").write_text(json.dumps(event) + "\n")
|
||||
projected = read_trace_events(tmp_path, request_id=request_id)
|
||||
assert projected == (
|
||||
{
|
||||
"timestamp": "2026-10-23T01:00:00Z",
|
||||
"component": "diagnostic.redacted",
|
||||
"event": "diagnostic.redacted",
|
||||
"outcome": "redacted",
|
||||
"details": {"phase": "recall", "count": 1},
|
||||
},
|
||||
)
|
||||
|
||||
external = tmp_path / "external"
|
||||
external.mkdir()
|
||||
(logs / "diagnostic-core.jsonl").unlink()
|
||||
logs.rmdir()
|
||||
logs.symlink_to(external, target_is_directory=True)
|
||||
with pytest.raises(ValueError, match="diagnostic_directory_unsafe"):
|
||||
read_trace_events(tmp_path, request_id=request_id)
|
||||
|
||||
|
||||
def test_credential_detection_is_conservative_and_matches_export_rules() -> None:
|
||||
for value in (
|
||||
"ASIA1234567890ABCDEF",
|
||||
"GOCSPX-clientSecret_A1",
|
||||
"Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
|
||||
"Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8_",
|
||||
):
|
||||
assert diagnostic_log._contains_credential_material(value)
|
||||
for value in (
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"abcdef1234567890abcdef1234567890abcdef12",
|
||||
"cursor",
|
||||
):
|
||||
assert not diagnostic_log._contains_credential_material(value)
|
||||
|
||||
|
||||
def test_trace_reader_never_follows_a_diagnostic_file_symlink(tmp_path) -> None:
|
||||
logs = tmp_path / "logs"
|
||||
logs.mkdir()
|
||||
outside = tmp_path / "outside.jsonl"
|
||||
outside.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"timestamp": "2026-10-23T01:00:00Z",
|
||||
"request_id": "request-1",
|
||||
"component": "core.api",
|
||||
"event": "memory.request.completed",
|
||||
"outcome": "succeeded",
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
(logs / "diagnostic-core.jsonl").symlink_to(outside)
|
||||
assert read_trace_events(tmp_path, request_id="request-1") == ()
|
||||
# Rejected, so nothing is written — but the call returned.
|
||||
assert not receipt.path.exists() or receipt.path.read_text() == ""
|
||||
|
||||
|
||||
def test_the_sedimentation_toggle_payload_is_recorded(tmp_path, monkeypatch) -> None:
|
||||
"""`enabled` is a bounded boolean and has to be a legal detail key.
|
||||
|
||||
Omitting it from the allow-list is what made the switch's own endpoint
|
||||
answer 500 while still saving the new value.
|
||||
"""
|
||||
monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1")
|
||||
receipt = configure_diagnostics(tmp_path)
|
||||
try:
|
||||
@@ -451,7 +181,7 @@ def test_exception_diagnostic_keeps_code_location_not_message():
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
details={"prompt": f"{secret}\n"},
|
||||
details={"prompt": secret},
|
||||
)
|
||||
except ValueError as error:
|
||||
safe = diagnostic_exception(error)
|
||||
@@ -461,28 +191,6 @@ def test_exception_diagnostic_keeps_code_location_not_message():
|
||||
assert str(__file__) not in json.dumps(safe)
|
||||
|
||||
|
||||
def test_report_exception_never_writes_the_exception_message_to_diagnostics(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1")
|
||||
receipt = configure_diagnostics(tmp_path)
|
||||
secret = "sk-live-private-provider-response"
|
||||
try:
|
||||
report_exception(
|
||||
"core.test",
|
||||
"core.test.failed",
|
||||
RuntimeError(f"provider failed with {secret} at /Users/private/query.txt"),
|
||||
)
|
||||
finally:
|
||||
reset_diagnostics()
|
||||
payload = receipt.path.read_text()
|
||||
assert secret not in payload
|
||||
assert "/Users/private" not in payload
|
||||
event = json.loads(payload)
|
||||
assert event["details"] == {"count": 1}
|
||||
assert event["error_type"] == "RuntimeError"
|
||||
|
||||
|
||||
def test_old_logs_are_removed_but_symlinks_are_never_followed(tmp_path):
|
||||
log = DiagnosticLog(tmp_path)
|
||||
log.path.parent.mkdir(parents=True)
|
||||
|
||||
@@ -449,7 +449,7 @@ def test_invalid_input_is_still_rejected_on_the_caller_side(tmp_path):
|
||||
component="hy.bridge",
|
||||
event="hy.failed",
|
||||
outcome="failed",
|
||||
details={"invalid key": "private"},
|
||||
details={"prompt": "private"},
|
||||
)
|
||||
log.close()
|
||||
|
||||
|
||||
@@ -1,57 +1,22 @@
|
||||
"""An instrument must never be able to break the thing it measures.
|
||||
|
||||
`diagnostic_event` validates its payload: a malformed `details` key, a missing
|
||||
`level`, or an unsafe detail value is rejected. A diagnostic payload must never
|
||||
be able to break the request path or loop heartbeat it is intended to measure.
|
||||
`diagnostic_event` validates its payload: an unlisted `details` key, a missing
|
||||
`level`, or a detail value over 256 characters all raise. Two of our own nodes
|
||||
shipped with unlisted keys, and because they sat on a request path and on the
|
||||
loop heartbeat, they turned every external recall into a 500 and then killed
|
||||
the process. Both were caught only by running the built runtime, not by tests.
|
||||
|
||||
So this checks the payloads at their source, by walking every literal call.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from one_memory.diagnostic_log import _DETAIL_KEYS
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent / "src" / "one_memory"
|
||||
DETAIL_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,63}$")
|
||||
REFUSED_DETAIL_KEYS = {"__proto__", "prototype", "constructor"}
|
||||
EXPORTED_STRING_DETAIL_KEYS = {
|
||||
"action",
|
||||
"method",
|
||||
"mode",
|
||||
"phase",
|
||||
"platform",
|
||||
"reason",
|
||||
"source_id",
|
||||
"status",
|
||||
"version",
|
||||
"parent_session_id",
|
||||
"runtime_state",
|
||||
"core_state",
|
||||
"broker_state",
|
||||
"instance_id",
|
||||
"operation_kind",
|
||||
"file_kind",
|
||||
"last_probe",
|
||||
"signal",
|
||||
}
|
||||
PRIVATE_DETAIL_KEYS = {
|
||||
"api_key",
|
||||
"authorization",
|
||||
"content",
|
||||
"credential",
|
||||
"memory",
|
||||
"memory_text",
|
||||
"message",
|
||||
"messages",
|
||||
"password",
|
||||
"prompt",
|
||||
"query",
|
||||
"raw_text",
|
||||
"secret",
|
||||
"token",
|
||||
}
|
||||
|
||||
#: Every module, not a hand-picked list. The two payloads that caused real
|
||||
#: outages were in files nobody thought to add here, so the only shape of this
|
||||
@@ -68,19 +33,6 @@ def _literal_calls(path: Path):
|
||||
yield node
|
||||
|
||||
|
||||
def _contains_free_text_conversion(node: ast.AST) -> bool:
|
||||
for item in ast.walk(node):
|
||||
if (
|
||||
isinstance(item, ast.Call)
|
||||
and isinstance(item.func, ast.Name)
|
||||
and item.func.id in {"str", "repr", "_safe_message"}
|
||||
):
|
||||
return True
|
||||
if isinstance(item, ast.Attribute) and item.attr in {"args", "message"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relative", SOURCES)
|
||||
def test_every_diagnostic_payload_is_valid(relative: str) -> None:
|
||||
path = ROOT / relative
|
||||
@@ -93,22 +45,7 @@ def test_every_diagnostic_payload_is_valid(relative: str) -> None:
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg != "details" or not isinstance(keyword.value, ast.Dict):
|
||||
continue
|
||||
for key, expression in zip(keyword.value.keys, keyword.value.values, strict=True):
|
||||
if not isinstance(key, ast.Constant):
|
||||
continue
|
||||
value = key.value
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not DETAIL_KEY.fullmatch(value)
|
||||
or value in REFUSED_DETAIL_KEYS
|
||||
):
|
||||
problems.append(f"{relative}:{node.lineno}: invalid details key {value!r}")
|
||||
elif value.lower() in PRIVATE_DETAIL_KEYS:
|
||||
problems.append(f"{relative}:{node.lineno}: private details key {value!r}")
|
||||
elif value in EXPORTED_STRING_DETAIL_KEYS and _contains_free_text_conversion(
|
||||
expression
|
||||
):
|
||||
problems.append(
|
||||
f"{relative}:{node.lineno}: exported detail {value!r} uses free text"
|
||||
)
|
||||
for key in keyword.value.keys:
|
||||
if isinstance(key, ast.Constant) and key.value not in _DETAIL_KEYS:
|
||||
problems.append(f"{relative}:{node.lineno}: unlisted details key {key.value!r}")
|
||||
assert not problems, "\n".join(problems)
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from io import StringIO
|
||||
@@ -21,7 +20,6 @@ from one_memory.memory_server.durable_queue import (
|
||||
DurableWriteQueue,
|
||||
execute_job,
|
||||
)
|
||||
from one_memory.sdk_write_processor import SdkWriteReceipt
|
||||
|
||||
|
||||
def _grant(tmp_path, source_id: str, *, sync: bool) -> EvidenceStore:
|
||||
@@ -235,14 +233,14 @@ def test_hook_job_completes_after_file_identity_adoption(tmp_path) -> None:
|
||||
).fetchone()
|
||||
|
||||
class IdentityCheckingSupervisor:
|
||||
async def process_sdk_job(
|
||||
async def process_records(
|
||||
self,
|
||||
records,
|
||||
*,
|
||||
request_id,
|
||||
before_hy_invocation,
|
||||
source_admitted,
|
||||
):
|
||||
assert request_id
|
||||
assert source_admitted is True
|
||||
with closing(sqlite3.connect(database)) as connection:
|
||||
connection.execute("BEGIN")
|
||||
for record in records:
|
||||
@@ -251,7 +249,7 @@ def test_hook_job_completes_after_file_identity_adoption(tmp_path) -> None:
|
||||
assert ingest_journal.matches_current(connection, current)
|
||||
connection.rollback()
|
||||
await before_hy_invocation()
|
||||
return SdkWriteReceipt()
|
||||
return ()
|
||||
|
||||
drain = DurableWriteDrain(queue, execute=lambda _job: None)
|
||||
assert drain.claim(limit=1) == (job_id,)
|
||||
@@ -562,30 +560,6 @@ def test_capture_failure_stays_silent(tmp_path, monkeypatch) -> None:
|
||||
assert output.getvalue() == ""
|
||||
|
||||
|
||||
def test_capture_is_a_silent_noop_during_sdk_migration(tmp_path, monkeypatch) -> None:
|
||||
database = tmp_path / "one-memory.sqlite3"
|
||||
marker = tmp_path / ".sdk-migration-capture-fence.json"
|
||||
marker.write_text(json.dumps({"schema_version": 1, "pid": os.getpid()}))
|
||||
monkeypatch.setattr("one_memory.live_capture.database_path", lambda: database)
|
||||
monkeypatch.setattr(
|
||||
"sys.stdin",
|
||||
StringIO(
|
||||
json.dumps(
|
||||
{
|
||||
"hook_event_name": "beforeSubmitPrompt",
|
||||
"conversation_id": "session",
|
||||
"prompt": "not persisted during migration",
|
||||
}
|
||||
)
|
||||
),
|
||||
)
|
||||
output = StringIO()
|
||||
|
||||
assert cli.run(["capture", "cursor"], output=output) == 0
|
||||
assert output.getvalue() == ""
|
||||
assert not database.exists()
|
||||
|
||||
|
||||
def test_codex_harness_preamble_is_not_a_user_message(tmp_path) -> None:
|
||||
database = tmp_path / "one-memory.sqlite3"
|
||||
_grant(tmp_path, "codex", sync=True)
|
||||
|
||||
@@ -102,8 +102,6 @@ def test_stall_captures_a_safe_stack_for_every_thread() -> None:
|
||||
|
||||
assert watchdog.stall_stacks
|
||||
captured = watchdog.stall_stacks[0]
|
||||
assert captured
|
||||
assert len(captured) <= 16
|
||||
assert all(line.startswith("thread|") for line in captured)
|
||||
assert all("source-reader-test" not in line and "MainThread" not in line for line in captured)
|
||||
assert any(line.startswith("thread=MainThread|") for line in captured)
|
||||
assert any(line.startswith("thread=source-reader-test|") for line in captured)
|
||||
assert all("/Users/" not in line and "\n" not in line for line in captured)
|
||||
@@ -2,10 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from hy_memory.models.memory import MemoryLayer, MemoryStatus
|
||||
@@ -27,7 +23,6 @@ from one_memory.sdk_data_plane_migration import (
|
||||
MemoryAuthority,
|
||||
SdkDataPlaneMigrationStore,
|
||||
)
|
||||
from one_memory.sdk_memory_runtime import SdkMemoryRuntime
|
||||
from one_memory.sdk_migration_bundle import create_sdk_migration_bundle
|
||||
from one_memory.sdk_migration_inventory import (
|
||||
LegacyMemoryInventory,
|
||||
@@ -224,7 +219,7 @@ def test_startup_cutover_freezes_pending_job_input_and_preserves_the_queue(store
|
||||
database_path=store.database_path,
|
||||
migration_id="m1",
|
||||
bundle_id="bundle-1",
|
||||
sdk_version="2.0.5",
|
||||
sdk_version="2.0.5.dev3",
|
||||
)
|
||||
assert marker.migration_id == "m1"
|
||||
assert legacy_tables_present(store.database_path)
|
||||
@@ -371,12 +366,6 @@ def _legacy(
|
||||
status="active",
|
||||
layer="l2_fact",
|
||||
):
|
||||
productivity = {
|
||||
"coding_experience": ("experience", "coding"),
|
||||
"working_experience": ("experience", "working"),
|
||||
"coding_fact": ("fact", "coding"),
|
||||
"working_fact": ("fact", None),
|
||||
}.get(layer)
|
||||
return LegacyMemoryInventory(
|
||||
legacy_memory_id=legacy_id,
|
||||
canonical_memory_id=canonical_id or legacy_id,
|
||||
@@ -391,27 +380,16 @@ def _legacy(
|
||||
tags=(),
|
||||
supersedes=(),
|
||||
superseded_by=(),
|
||||
productivity_kind=productivity[0] if productivity else None,
|
||||
productivity_variant=productivity[1] if productivity else None,
|
||||
)
|
||||
|
||||
|
||||
def _sdk(
|
||||
memory_id,
|
||||
*,
|
||||
content="body",
|
||||
status="active",
|
||||
productivity_kind=None,
|
||||
productivity_variant=None,
|
||||
):
|
||||
def _sdk(memory_id, *, content="body", status="active"):
|
||||
return SdkMemoryInventory(
|
||||
memory_id=memory_id,
|
||||
user_id="local",
|
||||
agent_id="one-memory",
|
||||
content_hash=content_hash(content),
|
||||
status=status,
|
||||
productivity_kind=productivity_kind,
|
||||
productivity_variant=productivity_variant,
|
||||
)
|
||||
|
||||
|
||||
@@ -442,7 +420,7 @@ def test_inventory_classification_covers_every_legacy_and_sdk_state():
|
||||
assert by_key["memory:delete-existing"].action == "delete"
|
||||
assert by_key["memory:divergent"].action == "quarantine"
|
||||
assert by_key["memory:unknown-layer"].reason == "legacy_layer_unknown"
|
||||
assert by_key["sdk:chat:sdk-only"].action == "adopt"
|
||||
assert by_key["sdk:sdk-only"].action == "adopt"
|
||||
assert len(decisions) == 8
|
||||
|
||||
|
||||
@@ -452,10 +430,7 @@ class _MigrationClient:
|
||||
self.imported = []
|
||||
self.imported_raw = []
|
||||
self.deleted = []
|
||||
self.deleted_productivity = []
|
||||
self.deleted_raw = []
|
||||
self.raw = ()
|
||||
self.productivity = {}
|
||||
|
||||
async def list_memories(self, *, limit, offset, **_kwargs):
|
||||
page = self.memories[offset : offset + limit]
|
||||
@@ -474,34 +449,6 @@ class _MigrationClient:
|
||||
self.deleted.append(memory_id)
|
||||
return {"success": True, "deleted_count": 1}
|
||||
|
||||
async def read_productivity_memory(self, memory_id, *, kind, variant):
|
||||
if variant is not None:
|
||||
memory = self.productivity.get((memory_id, kind, variant))
|
||||
return None if memory is None else (memory, variant)
|
||||
for candidate in ("working", "coding"):
|
||||
memory = self.productivity.get((memory_id, kind, candidate))
|
||||
if memory is not None:
|
||||
return memory, candidate
|
||||
return None
|
||||
|
||||
async def list_productivity_memories(self):
|
||||
return tuple(
|
||||
{
|
||||
"memory_id": memory.memory_id,
|
||||
"content": memory.content,
|
||||
"status": memory.status or "active",
|
||||
"user_id": memory.user_id,
|
||||
"agent_id": memory.agent_id,
|
||||
"kind": kind,
|
||||
"variant": variant,
|
||||
}
|
||||
for (_memory_id, kind, variant), memory in self.productivity.items()
|
||||
)
|
||||
|
||||
async def delete_productivity_memory(self, memory_id, *, kind, variant, user_id):
|
||||
self.deleted_productivity.append((memory_id, kind, variant, user_id))
|
||||
return {"success": True, "deleted_count": 1}
|
||||
|
||||
async def read_raw_inventory(self, *, limit, offset, **_kwargs):
|
||||
rows = self.raw[offset : offset + limit]
|
||||
return HySdkRawPage(
|
||||
@@ -515,10 +462,6 @@ class _MigrationClient:
|
||||
self.imported_raw.append(kwargs)
|
||||
return {"success": True, "raw_id": kwargs["raw_id"]}
|
||||
|
||||
async def delete_raw(self, raw_id):
|
||||
self.deleted_raw.append(raw_id)
|
||||
return {"success": True, "deleted_count": 1}
|
||||
|
||||
async def get_sdk_memory(self, memory_id):
|
||||
return next(
|
||||
(memory for memory in self.memories if memory.memory_id == memory_id),
|
||||
@@ -557,84 +500,6 @@ def test_sdk_inventory_is_paged_without_dropping_lifecycle_records():
|
||||
]
|
||||
|
||||
|
||||
def test_sdk_inventory_adopts_mapped_productivity_ids_and_deletes_user_removed_fact(store):
|
||||
active = _legacy("exp", layer="coding_experience")
|
||||
deleted = _legacy("fact", layer="working_fact", status="deleted")
|
||||
client = _MigrationClient()
|
||||
client.productivity = {
|
||||
("exp", "experience", "coding"): HySdkMemoryRecord(
|
||||
memory_id="exp",
|
||||
content="sdk experience",
|
||||
layer="experience",
|
||||
status="active",
|
||||
is_latest=True,
|
||||
user_id="local",
|
||||
agent_id="scope",
|
||||
),
|
||||
("fact", "fact", "coding"): HySdkMemoryRecord(
|
||||
memory_id="fact",
|
||||
content="sdk fact",
|
||||
layer="fact",
|
||||
status="active",
|
||||
is_latest=True,
|
||||
user_id="local",
|
||||
agent_id="scope",
|
||||
),
|
||||
("sdk-only", "experience", "working"): HySdkMemoryRecord(
|
||||
memory_id="sdk-only",
|
||||
content="sdk only",
|
||||
layer="experience",
|
||||
status="active",
|
||||
is_latest=True,
|
||||
user_id="local",
|
||||
agent_id="another-scope",
|
||||
),
|
||||
}
|
||||
sdk = asyncio.run(read_sdk_memory_inventory(client, legacy=(active, deleted)))
|
||||
_start(store)
|
||||
decisions = record_memory_migration_plan(
|
||||
store,
|
||||
migration_id="m1",
|
||||
legacy=(active, deleted),
|
||||
sdk=sdk,
|
||||
)
|
||||
by_key = {decision.item_key: decision for decision in decisions}
|
||||
assert by_key["memory:exp"].action == "matched"
|
||||
assert by_key["memory:fact"].action == "delete"
|
||||
assert by_key["sdk:working:experience:sdk-only"].action == "adopt"
|
||||
|
||||
asyncio.run(
|
||||
execute_memory_migration_plan(
|
||||
client,
|
||||
store,
|
||||
migration_id="m1",
|
||||
legacy=(active, deleted),
|
||||
decisions=decisions,
|
||||
)
|
||||
)
|
||||
assert client.imported == []
|
||||
assert client.deleted_productivity == [("fact", "fact", None, "local")]
|
||||
|
||||
|
||||
def test_productivity_id_in_both_variants_is_quarantined():
|
||||
legacy = (_legacy("fact", layer="working_fact"),)
|
||||
sdk = (
|
||||
_sdk(
|
||||
"fact",
|
||||
productivity_kind="fact",
|
||||
productivity_variant="working",
|
||||
),
|
||||
_sdk(
|
||||
"fact",
|
||||
productivity_kind="fact",
|
||||
productivity_variant="coding",
|
||||
),
|
||||
)
|
||||
(decision, *_sdk_only) = classify_memory_inventory(legacy, sdk)
|
||||
assert decision.action == "quarantine"
|
||||
assert decision.reason == "sdk_memory_id_ambiguous"
|
||||
|
||||
|
||||
def test_recorded_plan_executes_import_delete_alias_and_adoption(store):
|
||||
_start(store)
|
||||
legacy = (
|
||||
@@ -673,218 +538,32 @@ def test_recorded_plan_executes_import_delete_alias_and_adoption(store):
|
||||
assert progress.counts == {"adopted": 1, "deleted": 2, "verified": 3}
|
||||
|
||||
|
||||
def test_legacy_raw_inventory_builds_recent_user_assistant_turns_without_tools(store):
|
||||
def test_legacy_raw_inventory_preserves_exact_source_metadata(store):
|
||||
database = store.database_path
|
||||
with main_database(database).write() as connection, connection:
|
||||
connection.executemany(
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO evidence (
|
||||
evidence_id,idempotency_key,user_id,source_id,workspace,project,
|
||||
conversation_id,message_id,source_ordinal,content_kind,role,
|
||||
written_by,created_at,verbatim,content_hash,revision,
|
||||
imported_at,updated_at,ingest_seq
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
) VALUES (
|
||||
'e1','key','local','cursor','/repo','project','session','message',
|
||||
'7','raw_conversation','user','person','2026-10-01T00:00:00+00:00',
|
||||
'verbatim',?,2,'2026-10-02T00:00:00+00:00',
|
||||
'2026-10-02T00:00:00+00:00',1
|
||||
)
|
||||
""",
|
||||
(
|
||||
(
|
||||
"u1",
|
||||
"k1",
|
||||
"local",
|
||||
"cursor",
|
||||
"/repo",
|
||||
"project",
|
||||
"session",
|
||||
"m1",
|
||||
"1",
|
||||
"raw_conversation",
|
||||
"user",
|
||||
"person",
|
||||
"2026-10-22T00:00:00+00:00",
|
||||
"question",
|
||||
content_hash("question"),
|
||||
1,
|
||||
"2026-10-22T00:00:00+00:00",
|
||||
"2026-10-22T00:00:00+00:00",
|
||||
1,
|
||||
),
|
||||
(
|
||||
"t1",
|
||||
"k2",
|
||||
"local",
|
||||
"cursor",
|
||||
"/repo",
|
||||
"project",
|
||||
"session",
|
||||
"m2",
|
||||
"2",
|
||||
"raw_conversation",
|
||||
"tool",
|
||||
"tool",
|
||||
"2026-10-22T00:00:01+00:00",
|
||||
"secret tool output",
|
||||
content_hash("secret tool output"),
|
||||
1,
|
||||
"2026-10-22T00:00:01+00:00",
|
||||
"2026-10-22T00:00:01+00:00",
|
||||
2,
|
||||
),
|
||||
(
|
||||
"a1",
|
||||
"k3",
|
||||
"local",
|
||||
"cursor",
|
||||
"/repo",
|
||||
"project",
|
||||
"session",
|
||||
"m3",
|
||||
"3",
|
||||
"raw_conversation",
|
||||
"assistant",
|
||||
"agent",
|
||||
"2026-10-22T00:00:02+00:00",
|
||||
"answer",
|
||||
content_hash("answer"),
|
||||
1,
|
||||
"2026-10-22T00:00:02+00:00",
|
||||
"2026-10-22T00:00:02+00:00",
|
||||
3,
|
||||
),
|
||||
(
|
||||
"old-u",
|
||||
"k4",
|
||||
"local",
|
||||
"cursor",
|
||||
"/repo",
|
||||
"project",
|
||||
"old-session",
|
||||
"m4",
|
||||
"4",
|
||||
"raw_conversation",
|
||||
"user",
|
||||
"person",
|
||||
"2026-10-01T00:00:00+00:00",
|
||||
"old question",
|
||||
content_hash("old question"),
|
||||
1,
|
||||
"2026-10-01T00:00:00+00:00",
|
||||
"2026-10-01T00:00:00+00:00",
|
||||
4,
|
||||
),
|
||||
(
|
||||
"old-a",
|
||||
"k5",
|
||||
"local",
|
||||
"cursor",
|
||||
"/repo",
|
||||
"project",
|
||||
"old-session",
|
||||
"m5",
|
||||
"5",
|
||||
"raw_conversation",
|
||||
"assistant",
|
||||
"agent",
|
||||
"2026-10-01T00:00:01+00:00",
|
||||
"old answer",
|
||||
content_hash("old answer"),
|
||||
1,
|
||||
"2026-10-01T00:00:01+00:00",
|
||||
"2026-10-01T00:00:01+00:00",
|
||||
5,
|
||||
),
|
||||
),
|
||||
(content_hash("verbatim"),),
|
||||
)
|
||||
(raw,) = read_legacy_raw_inventory(
|
||||
database,
|
||||
cutoff=datetime.fromisoformat("2026-10-20T00:00:00+00:00"),
|
||||
)
|
||||
assert raw.evidence_id.startswith("turn:")
|
||||
(raw,) = read_legacy_raw_inventory(database)
|
||||
assert raw.evidence_id == "e1"
|
||||
assert raw.session_id == "session"
|
||||
assert raw.source == "cursor"
|
||||
assert raw.agent_id == "all-apps"
|
||||
assert raw.content == "[user]: question\n[assistant]: answer"
|
||||
assert raw.metadata["evidence_ids"] == ["u1", "a1"]
|
||||
assert "tool" not in raw.content
|
||||
assert raw.memory_at.isoformat() == "2026-10-22T00:00:00+00:00"
|
||||
|
||||
|
||||
def test_raw_turn_cutoff_uses_completion_time_and_keeps_workspaces_separate(store):
|
||||
database = store.database_path
|
||||
rows = (
|
||||
("u0", "k0", "shared", "w0", "user", "person", "before", "2026-10-19T23:59:00+00:00", 1),
|
||||
("a0", "k1", "shared", "w0", "assistant", "agent", "after", "2026-10-20T00:01:00+00:00", 2),
|
||||
("u1", "k2", "same-session", "w1", "user", "person", "one", "2026-10-21T00:00:00+00:00", 3),
|
||||
(
|
||||
"a1",
|
||||
"k3",
|
||||
"same-session",
|
||||
"w1",
|
||||
"assistant",
|
||||
"agent",
|
||||
"first",
|
||||
"2026-10-21T00:01:00+00:00",
|
||||
4,
|
||||
),
|
||||
("u2", "k4", "same-session", "w2", "user", "person", "two", "2026-10-21T00:02:00+00:00", 5),
|
||||
(
|
||||
"a2",
|
||||
"k5",
|
||||
"same-session",
|
||||
"w2",
|
||||
"assistant",
|
||||
"agent",
|
||||
"second",
|
||||
"2026-10-21T00:03:00+00:00",
|
||||
6,
|
||||
),
|
||||
)
|
||||
with main_database(database).write() as connection, connection:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO evidence (
|
||||
evidence_id,idempotency_key,user_id,source_id,conversation_id,
|
||||
workspace,content_kind,role,written_by,verbatim,content_hash,
|
||||
revision,created_at,imported_at,updated_at,ingest_seq
|
||||
) VALUES (?,?,'local','cursor',?,?,'raw_conversation',?,?,?,? ,1,?,?,?,?)
|
||||
""",
|
||||
tuple(
|
||||
(
|
||||
evidence_id,
|
||||
key,
|
||||
session,
|
||||
workspace,
|
||||
role,
|
||||
written_by,
|
||||
text,
|
||||
content_hash(text),
|
||||
timestamp,
|
||||
timestamp,
|
||||
timestamp,
|
||||
sequence,
|
||||
)
|
||||
for (
|
||||
evidence_id,
|
||||
key,
|
||||
session,
|
||||
workspace,
|
||||
role,
|
||||
written_by,
|
||||
text,
|
||||
timestamp,
|
||||
sequence,
|
||||
) in rows
|
||||
),
|
||||
)
|
||||
|
||||
turns = read_legacy_raw_inventory(
|
||||
database,
|
||||
cutoff=datetime.fromisoformat("2026-10-20T00:00:00+00:00"),
|
||||
)
|
||||
assert {turn.content for turn in turns} == {
|
||||
"[user]: before\n[assistant]: after",
|
||||
"[user]: one\n[assistant]: first",
|
||||
"[user]: two\n[assistant]: second",
|
||||
}
|
||||
assert {turn.metadata["workspace"] for turn in turns} == {"w0", "w1", "w2"}
|
||||
assert raw.metadata["role"] == "user"
|
||||
assert raw.metadata["revision"] == 2
|
||||
assert raw.memory_at.isoformat() == "2026-10-01T00:00:00+00:00"
|
||||
|
||||
|
||||
def _legacy_raw(evidence_id, *, content="verbatim"):
|
||||
@@ -912,7 +591,7 @@ def _sdk_raw(raw_id, *, content="verbatim"):
|
||||
)
|
||||
|
||||
|
||||
def test_raw_inventory_matches_turns_imports_missing_and_deletes_noncanonical_sdk_raw(store):
|
||||
def test_raw_inventory_matches_exact_scope_imports_missing_and_adopts_sdk_only(store):
|
||||
legacy = (_legacy_raw("e1"), _legacy_raw("e2", content="missing"))
|
||||
sdk = (_sdk_raw("raw-existing"), _sdk_raw("raw-sdk-only", content="sdk-only"))
|
||||
decisions = classify_raw_inventory(legacy, sdk)
|
||||
@@ -921,7 +600,7 @@ def test_raw_inventory_matches_turns_imports_missing_and_deletes_noncanonical_sd
|
||||
assert by_key["raw:e1"].raw_id == "raw-existing"
|
||||
assert by_key["raw:e2"].action == "import_exact"
|
||||
assert by_key["raw:e2"].raw_id == "evidence:e2"
|
||||
assert by_key["sdk-raw:raw-sdk-only"].action == "delete"
|
||||
assert by_key["sdk-raw:raw-sdk-only"].action == "adopt"
|
||||
|
||||
_start(store)
|
||||
recorded = record_raw_migration_plan(
|
||||
@@ -941,8 +620,7 @@ def test_raw_inventory_matches_turns_imports_missing_and_deletes_noncanonical_sd
|
||||
)
|
||||
)
|
||||
assert [item["raw_id"] for item in client.imported_raw] == ["evidence:e2"]
|
||||
assert client.deleted_raw == ["raw-sdk-only"]
|
||||
assert store.progress("m1").counts == {"deleted": 1, "verified": 2}
|
||||
assert store.progress("m1").counts == {"adopted": 1, "verified": 2}
|
||||
|
||||
|
||||
def test_sdk_raw_inventory_pages_all_records():
|
||||
@@ -997,20 +675,6 @@ def test_offline_backfill_reaches_ready_without_committing_cutover(store):
|
||||
""",
|
||||
(content_hash("verbatim"),),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO evidence (
|
||||
evidence_id,idempotency_key,user_id,source_id,conversation_id,
|
||||
content_kind,role,written_by,verbatim,content_hash,revision,
|
||||
imported_at,updated_at,ingest_seq
|
||||
) VALUES (
|
||||
'a1','assistant-key','local','cursor','session','raw_conversation',
|
||||
'assistant','agent','reply',?,1,
|
||||
'2026-10-21T00:00:01+00:00','2026-10-21T00:00:01+00:00',2
|
||||
)
|
||||
""",
|
||||
(content_hash("reply"),),
|
||||
)
|
||||
client = _MigrationClient()
|
||||
result = asyncio.run(
|
||||
run_sdk_data_plane_backfill(
|
||||
@@ -1019,7 +683,7 @@ def test_offline_backfill_reaches_ready_without_committing_cutover(store):
|
||||
migration_id="migration",
|
||||
bundle_id="bundle",
|
||||
source_runtime_build="v1.0.4",
|
||||
target_sdk_version="2.0.5",
|
||||
target_sdk_version="2.0.5.dev3",
|
||||
source_schema_version=55,
|
||||
namespace_fingerprint="namespace",
|
||||
backend_fingerprint="backend",
|
||||
@@ -1030,9 +694,7 @@ def test_offline_backfill_reaches_ready_without_committing_cutover(store):
|
||||
assert result.phase == "reconciled"
|
||||
assert result.counts == {"verified": 2}
|
||||
assert [item["memory_id"] for item in client.imported] == ["memory-1"]
|
||||
assert len(client.imported_raw) == 1
|
||||
assert client.imported_raw[0]["content"] == "[user]: verbatim\n[assistant]: reply"
|
||||
migrated_raw_id = client.imported_raw[0]["raw_id"]
|
||||
assert [item["raw_id"] for item in client.imported_raw] == ["evidence:e1"]
|
||||
assert store.authority() is MemoryAuthority.LEGACY
|
||||
# Simulate a process dying after both SDK side effects but before startup
|
||||
# could finish. The next inventory observes them as matched rather than
|
||||
@@ -1041,13 +703,13 @@ def test_offline_backfill_reaches_ready_without_committing_cutover(store):
|
||||
client.memories = (_record("memory-1", content="preference"),)
|
||||
client.raw = (
|
||||
HySdkRawRecord(
|
||||
raw_id=migrated_raw_id,
|
||||
content="[user]: verbatim\n[assistant]: reply",
|
||||
raw_id="evidence:e1",
|
||||
content="verbatim",
|
||||
user_id="local",
|
||||
agent_id="all-apps",
|
||||
agent_id="one-memory",
|
||||
session_id="session",
|
||||
source="cursor",
|
||||
custom={"input_kind": "legacy_turn"},
|
||||
custom={"role": "user", "written_by": "person"},
|
||||
),
|
||||
)
|
||||
resumed = asyncio.run(
|
||||
@@ -1057,7 +719,7 @@ def test_offline_backfill_reaches_ready_without_committing_cutover(store):
|
||||
migration_id="migration",
|
||||
bundle_id="bundle",
|
||||
source_runtime_build="v1.0.4",
|
||||
target_sdk_version="2.0.5",
|
||||
target_sdk_version="2.0.5.dev3",
|
||||
source_schema_version=55,
|
||||
namespace_fingerprint="namespace",
|
||||
backend_fingerprint="backend",
|
||||
@@ -1090,7 +752,7 @@ def test_offline_backfill_blocks_cutover_when_an_item_is_quarantined(store):
|
||||
migration_id="migration",
|
||||
bundle_id="bundle",
|
||||
source_runtime_build="v1.0.4",
|
||||
target_sdk_version="2.0.5",
|
||||
target_sdk_version="2.0.5.dev3",
|
||||
source_schema_version=55,
|
||||
namespace_fingerprint="namespace",
|
||||
backend_fingerprint="backend",
|
||||
@@ -1121,7 +783,9 @@ class _MigrationVectorStore:
|
||||
|
||||
async def list_by_user(self, *, user_id, layers, **_kwargs):
|
||||
return [
|
||||
node for node in self.nodes.values() if node.user_id == user_id and node.layer in layers
|
||||
node
|
||||
for node in self.nodes.values()
|
||||
if node.user_id == user_id and node.layer in layers
|
||||
]
|
||||
|
||||
|
||||
@@ -1139,53 +803,12 @@ class _MigrationGraphStore:
|
||||
return node.node_id
|
||||
|
||||
|
||||
class _MigrationFactVector:
|
||||
def __init__(self):
|
||||
self.deleted = []
|
||||
|
||||
async def delete(self, memory_id):
|
||||
self.deleted.append(memory_id)
|
||||
return True
|
||||
|
||||
|
||||
class _MigrationCodingStore:
|
||||
def __init__(self):
|
||||
self._conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute(
|
||||
"CREATE TABLE coding_memory_meta ("
|
||||
"experience_id TEXT PRIMARY KEY,user_id TEXT,agent_id TEXT,solution TEXT)"
|
||||
)
|
||||
self._conn.execute(
|
||||
"CREATE TABLE working_fact ("
|
||||
"fact_id TEXT PRIMARY KEY,user_id TEXT,agent_id TEXT,"
|
||||
"statement TEXT,status TEXT)"
|
||||
)
|
||||
self._conn.execute(
|
||||
"INSERT INTO working_fact VALUES ('fact-1','local','scope','fact body','active')"
|
||||
)
|
||||
self._conn.commit()
|
||||
self._lock = threading.Lock()
|
||||
self._fact_vec = _MigrationFactVector()
|
||||
|
||||
async def get_fact(self, memory_id, *, user_id):
|
||||
row = self._conn.execute(
|
||||
"SELECT 1 FROM working_fact WHERE fact_id=? AND user_id=?",
|
||||
(memory_id, user_id),
|
||||
).fetchone()
|
||||
return object() if row is not None else None
|
||||
|
||||
|
||||
class _PrivilegedMigrationClient:
|
||||
def __init__(self):
|
||||
self._loop_thread = _MigrationLoop()
|
||||
self._vector_store = _MigrationVectorStore()
|
||||
self._embed_service = _MigrationEmbedder()
|
||||
self._graph_store = _MigrationGraphStore()
|
||||
self._coding = _MigrationCodingStore()
|
||||
|
||||
async def _coding_store_for_exact_get(self, variant):
|
||||
return self._coding if variant == "coding" else None
|
||||
|
||||
def list_memories(self, **_kwargs):
|
||||
return {"vdb": {"memories": [], "total": 0}, "request_id": "list"}
|
||||
@@ -1235,36 +858,13 @@ def test_offline_migration_writes_sdk_stores_without_public_client_import_method
|
||||
)
|
||||
)
|
||||
raw_page = asyncio.run(migration.read_raw_inventory(user_id="local"))
|
||||
productivity = asyncio.run(migration.list_productivity_memories())
|
||||
deleted = asyncio.run(
|
||||
migration.delete_productivity_memory(
|
||||
"fact-1",
|
||||
kind="fact",
|
||||
variant=None,
|
||||
user_id="local",
|
||||
)
|
||||
)
|
||||
|
||||
assert raw_result["raw_id"] == "raw-1"
|
||||
assert memory_result["memory_id"] == "memory-1"
|
||||
assert [item.raw_id for item in raw_page.raw] == ["raw-1"]
|
||||
assert productivity == (
|
||||
{
|
||||
"memory_id": "fact-1",
|
||||
"content": "fact body",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "scope",
|
||||
"kind": "fact",
|
||||
"variant": "coding",
|
||||
},
|
||||
)
|
||||
assert client._vector_store.nodes["raw-1"].layer is MemoryLayer.L1_RAW
|
||||
assert client._vector_store.nodes["memory-1"].status is MemoryStatus.ACTIVE
|
||||
assert client._graph_store.nodes["memory-1"].content == "prefers tea"
|
||||
assert deleted["deleted_count"] == 1
|
||||
assert client._coding._fact_vec.deleted == ["fact-1"]
|
||||
assert client._coding._conn.execute("SELECT COUNT(*) FROM working_fact").fetchone()[0] == 0
|
||||
assert not hasattr(client, "import_raw")
|
||||
assert not hasattr(client, "import_memory_exact")
|
||||
|
||||
@@ -1298,57 +898,6 @@ def test_raw_http_dto_uses_the_unified_memory_list_shape():
|
||||
assert page.raw[0].custom == {"role": "user"}
|
||||
|
||||
|
||||
def test_sdk_raw_retention_keeps_only_the_latest_three_days():
|
||||
now = datetime(2026, 10, 23, tzinfo=UTC)
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.rows = [
|
||||
{
|
||||
"memory_id": "old",
|
||||
"memory_at": int((now - timedelta(days=4)).timestamp()),
|
||||
},
|
||||
{
|
||||
"memory_id": "recent",
|
||||
"memory_at": int((now - timedelta(days=2)).timestamp()),
|
||||
},
|
||||
]
|
||||
|
||||
def list_memories(self, **_kwargs):
|
||||
return {
|
||||
"vdb": {
|
||||
"memories": list(self.rows),
|
||||
"total": len(self.rows),
|
||||
"limit": 500,
|
||||
"offset": 0,
|
||||
}
|
||||
}
|
||||
|
||||
def delete(self, memory_id):
|
||||
before = len(self.rows)
|
||||
self.rows = [row for row in self.rows if row["memory_id"] != memory_id]
|
||||
return {
|
||||
"success": True,
|
||||
"deleted_count": before - len(self.rows),
|
||||
}
|
||||
|
||||
client = Client()
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return client
|
||||
|
||||
runtime = SdkMemoryRuntime(
|
||||
coordinator=None,
|
||||
read_handle=Handle(),
|
||||
write_queue=None,
|
||||
extraction_settings=None,
|
||||
data_dir=Path("."),
|
||||
)
|
||||
assert asyncio.run(runtime.prune_expired_raw(now=now)) == 1
|
||||
assert [row["memory_id"] for row in client.rows] == ["recent"]
|
||||
|
||||
|
||||
def _staging_fixture(tmp_path, store):
|
||||
data_dir = tmp_path / "home"
|
||||
live = data_dir / "hy-memory" / "governed-v2"
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
|
||||
assert manifest["bundle_id"] == "migration-1"
|
||||
assert manifest["sdk_namespace"] == "governed-v2"
|
||||
assert manifest["runtime_build"] == "build-13"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.5"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.5.dev3"
|
||||
paths = {item["path"]: item for item in manifest["files"]}
|
||||
assert {
|
||||
"management/one-memory.sqlite3",
|
||||
|
||||
Generated
+5
-5
@@ -991,7 +991,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hy-memory"
|
||||
version = "2.0.5"
|
||||
version = "2.0.5.dev3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "chromadb" },
|
||||
@@ -1008,9 +1008,9 @@ dependencies = [
|
||||
{ name = "scikit-learn" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/56/7a279122bb56b067c390b748b8a83b6a90bb2a8f237c1d3989b6317681cd/hy_memory-2.0.5.tar.gz", hash = "sha256:bfb1e9e9815377477702327fa318331d555b8256a67b5bad8d70d6a682bd9a4f", size = 839060, upload-time = "2026-10-22T12:59:30.502Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/83/67c261bc379eb45f1cc460cf55eb057867d485695c2799bd861827dbcedb/hy_memory-2.0.5.dev3.tar.gz", hash = "sha256:8ae8c2b2d9736b304d26654a5c2903160f430ec5065206ae724d5cb841820d75", size = 836317, upload-time = "2026-10-21T09:59:27.311Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/97/090ca18b6a13d0216d2df607cb67732ff7a8eba2d0359b9ad95283b2cbdc/hy_memory-2.0.5-py3-none-any.whl", hash = "sha256:c24649d188eaa03d3b4e4e4ec20e818da884170b5f775f3cf23ecfa6b60523d6", size = 902923, upload-time = "2026-10-22T12:59:28.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/5f/30a88b0278c070c63d11f0c25a360e1cfe130d90a53a5eece9359c4ca7ec/hy_memory-2.0.5.dev3-py3-none-any.whl", hash = "sha256:621193ac3a527c0b8736d66f17c4cce4956dc4a882d9dfc724b9df4d34fe816e", size = 900779, upload-time = "2026-10-21T09:59:24.716Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1801,7 +1801,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "one-memory"
|
||||
version = "1.0.5"
|
||||
version = "1.0.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiomysql" },
|
||||
@@ -1837,7 +1837,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||
{ name = "filelock", specifier = ">=3.32.4" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "hy-memory", specifier = "==2.0.5" },
|
||||
{ name = "hy-memory", specifier = "==2.0.5.dev3" },
|
||||
{ name = "keyring", specifier = ">=25.7.0" },
|
||||
{ name = "numpy", specifier = "==2.5.2" },
|
||||
{ name = "onnxruntime", specifier = "==1.23.2" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/desktop",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -94,14 +94,12 @@ it("returns only diagnostic events written during this desktop launch", async ()
|
||||
await mkdir(logs, { recursive: true });
|
||||
const target = path.join(logs, "diagnostic-core.jsonl");
|
||||
const old = {
|
||||
schema_version: 1,
|
||||
timestamp: "2026-10-14T09:00:00.000Z",
|
||||
level: "info",
|
||||
service: "core",
|
||||
component: "core.startup",
|
||||
event: "core.old",
|
||||
outcome: "succeeded",
|
||||
session_id: "11111111-1111-4111-8111-111111111111",
|
||||
};
|
||||
await writeFile(target, `${JSON.stringify(old)}\n`);
|
||||
const cursor = await captureDebugLogCursor(dataDir);
|
||||
@@ -111,11 +109,6 @@ it("returns only diagnostic events written during this desktop launch", async ()
|
||||
timestamp: "2026-10-14T10:01:00.000Z",
|
||||
event: "core.current",
|
||||
level: "warn",
|
||||
details: {
|
||||
count: 1,
|
||||
"custom.private": "/Users/private/query.txt",
|
||||
credential_hint: "ghp_private_token",
|
||||
},
|
||||
};
|
||||
await appendFile(target, `${JSON.stringify(current)}\nnot-json\n`);
|
||||
|
||||
@@ -125,11 +118,8 @@ it("returns only diagnostic events written during this desktop launch", async ()
|
||||
expect(snapshot.entries[0]).toMatchObject({
|
||||
event: "core.current",
|
||||
level: "warn",
|
||||
details: { count: 1 },
|
||||
source: "diagnostic",
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toContain("/Users/private");
|
||||
expect(JSON.stringify(snapshot)).not.toContain("ghp_private_token");
|
||||
});
|
||||
|
||||
it("tracks diagnostic JSONL across rotation without rereading the old prefix", async () => {
|
||||
@@ -140,14 +130,12 @@ it("tracks diagnostic JSONL across rotation without rereading the old prefix", a
|
||||
const target = path.join(logs, "diagnostic-core.jsonl");
|
||||
const event = (name: string, timestamp: string) =>
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
timestamp,
|
||||
level: "info",
|
||||
service: "core",
|
||||
component: "core.test",
|
||||
event: name,
|
||||
outcome: "succeeded",
|
||||
session_id: "22222222-2222-4222-8222-222222222222",
|
||||
});
|
||||
await writeFile(target, `${event("before", "2026-10-14T09:00:00.000Z")}\n`);
|
||||
const cursor = await captureDebugLogCursor(dataDir);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { lstat, open, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
type DiagnosticEventV1,
|
||||
type DiagnosticLogger,
|
||||
diagnosticEvent,
|
||||
diagnosticZipFilename,
|
||||
exportDiagnosticZip,
|
||||
inspectBackgroundHealth,
|
||||
projectDiagnosticEventForSafeOutput,
|
||||
recordDiagnosticMarker,
|
||||
} from "@one-memory/runtime-control";
|
||||
import type { BrowserWindow, IpcMainInvokeEvent } from "electron";
|
||||
@@ -373,58 +370,42 @@ function debugLogEntry(
|
||||
const timestamp = typeof value.timestamp === "string" ? value.timestamp : "";
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
if (!Number.isFinite(timestampMs) || timestampMs < startedAt) return null;
|
||||
let projected: DiagnosticEventV1;
|
||||
try {
|
||||
projected = projectDiagnosticEventForSafeOutput(
|
||||
diagnosticEvent({
|
||||
timestamp,
|
||||
level: value.level as never,
|
||||
service: value.service as never,
|
||||
component: value.component as never,
|
||||
event: value.event as never,
|
||||
outcome: value.outcome as never,
|
||||
session_id: value.session_id as never,
|
||||
...(typeof value.trace_id === "string"
|
||||
? { trace_id: value.trace_id }
|
||||
: {}),
|
||||
...(typeof value.duration_ms === "number"
|
||||
? { duration_ms: value.duration_ms }
|
||||
: {}),
|
||||
...(typeof value.error_code === "string"
|
||||
? { error_code: value.error_code }
|
||||
: {}),
|
||||
...(typeof value.error_type === "string"
|
||||
? { error_type: value.error_type }
|
||||
: {}),
|
||||
...(value.details && typeof value.details === "object"
|
||||
? { details: value.details as never }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
const level = value.level;
|
||||
const service = value.service;
|
||||
const component = value.component;
|
||||
const event = value.event;
|
||||
const outcome = value.outcome;
|
||||
if (
|
||||
!["info", "warn", "error"].includes(String(level)) ||
|
||||
!["runtime", "core", "host-bridge", "desktop", "cli"].includes(
|
||||
String(service),
|
||||
) ||
|
||||
typeof component !== "string" ||
|
||||
typeof event !== "string" ||
|
||||
typeof outcome !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const details = safeDebugDetails(value.details);
|
||||
return {
|
||||
id: `${filename}:${lineNumber}:${timestamp}`,
|
||||
timestamp: projected.timestamp,
|
||||
level: projected.level,
|
||||
service: projected.service,
|
||||
component: projected.component,
|
||||
event: projected.event,
|
||||
outcome: projected.outcome,
|
||||
...(projected.duration_ms === undefined
|
||||
? {}
|
||||
: { duration_ms: projected.duration_ms }),
|
||||
...(projected.error_code === undefined
|
||||
? {}
|
||||
: { error_code: projected.error_code }),
|
||||
...(projected.error_type === undefined
|
||||
? {}
|
||||
: { error_type: projected.error_type }),
|
||||
...(projected.trace_id === undefined
|
||||
? {}
|
||||
: { trace_id: projected.trace_id }),
|
||||
...(projected.details === undefined ? {} : { details: projected.details }),
|
||||
timestamp,
|
||||
level: level as DebugLogEntry["level"],
|
||||
service: service as DebugLogEntry["service"],
|
||||
component,
|
||||
event,
|
||||
outcome,
|
||||
...(typeof value.duration_ms === "number"
|
||||
? { duration_ms: value.duration_ms }
|
||||
: {}),
|
||||
...(typeof value.error_code === "string"
|
||||
? { error_code: value.error_code }
|
||||
: {}),
|
||||
...(typeof value.error_type === "string"
|
||||
? { error_type: value.error_type }
|
||||
: {}),
|
||||
...(typeof value.trace_id === "string" ? { trace_id: value.trace_id } : {}),
|
||||
...(details === undefined ? {} : { details }),
|
||||
source: "diagnostic",
|
||||
};
|
||||
}
|
||||
@@ -542,6 +523,25 @@ function logLineTimestamp(
|
||||
return new Date(Math.max(startedAt, fallback)).toISOString();
|
||||
}
|
||||
|
||||
function safeDebugDetails(
|
||||
value: unknown,
|
||||
): Record<string, string | number | boolean | null> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
return undefined;
|
||||
const details: Record<string, string | number | boolean | null> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (
|
||||
item === null ||
|
||||
typeof item === "string" ||
|
||||
typeof item === "number" ||
|
||||
typeof item === "boolean"
|
||||
) {
|
||||
details[key] = item;
|
||||
}
|
||||
}
|
||||
return Object.keys(details).length ? details : undefined;
|
||||
}
|
||||
|
||||
async function pipelineLogFiles(root: string): Promise<PipelineLogCursor[]> {
|
||||
const rootInfo = await lstat(root).catch(() => null);
|
||||
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) return [];
|
||||
|
||||
@@ -113,7 +113,6 @@ import {
|
||||
installServiceWithDisabledFallback,
|
||||
isInstalledApplicationsApp,
|
||||
stableRuntimeErrorCode,
|
||||
stableRuntimeUserMessage,
|
||||
startStableRuntimeRepair,
|
||||
} from "./stable-runtime";
|
||||
import { STARTUP_SCREEN_URL, startupScreenHtml } from "./startup-screen";
|
||||
@@ -1099,7 +1098,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
? "blocked"
|
||||
: "skipped",
|
||||
...(migration.code === undefined ? {} : { error_code: migration.code }),
|
||||
details: { runtime_state: migration.state },
|
||||
details: { state: migration.state },
|
||||
});
|
||||
requireCompletedStartupSdkMigration(migration);
|
||||
};
|
||||
@@ -1672,7 +1671,7 @@ if (desktopVersion !== null) {
|
||||
languagePreferences?.read().locale ?? "zh-CN",
|
||||
"nativeErrorTitle",
|
||||
),
|
||||
stableRuntimeUserMessage(error),
|
||||
error instanceof Error ? error.message : "desktop_unavailable",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -274,27 +274,4 @@ describe("startup install errors", () => {
|
||||
"MemHub 启动失败",
|
||||
);
|
||||
});
|
||||
|
||||
it("says why a data upgrade failed, and what the person can do", () => {
|
||||
const noSpace = stableRuntimeUserMessage(
|
||||
new Error("sdk_migration_disk_space_insufficient"),
|
||||
);
|
||||
// The only failure here the person fixes themselves: name the action.
|
||||
expect(noSpace).toContain("磁盘空间不足");
|
||||
expect(noSpace).toContain("重新启动");
|
||||
expect(noSpace).toContain("不会丢失");
|
||||
expect(noSpace).not.toContain("sdk_migration_disk_space_insufficient");
|
||||
|
||||
const noModel = stableRuntimeUserMessage(
|
||||
new Error("model_profile_not_active"),
|
||||
);
|
||||
expect(noModel).toContain("模型");
|
||||
expect(noModel).not.toContain("model_profile_not_active");
|
||||
|
||||
const coding = stableRuntimeUserMessage(
|
||||
new Error("sdk_migration_working_fact_conflict"),
|
||||
);
|
||||
expect(coding).toContain("Coding");
|
||||
expect(coding).toContain("不会丢失");
|
||||
});
|
||||
});
|
||||
@@ -126,23 +126,6 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
|
||||
"installed_version_conflict",
|
||||
"distribution_downgrade_refused",
|
||||
"install_current_unmanaged",
|
||||
// Migration runs before the app window exists, so these are the only names
|
||||
// a first launch can be given. They stay codes here and become sentences in
|
||||
// STABLE_RUNTIME_USER_MESSAGES: a person reading the dialog never sees them.
|
||||
"model_profile_not_active",
|
||||
"model_secret_unavailable",
|
||||
"sdk_migration_preflight_failed",
|
||||
"sdk_migration_disk_space_insufficient",
|
||||
"sdk_migration_staging_failed",
|
||||
"sdk_migration_backfill_failed",
|
||||
"sdk_migration_working_fact_conflict",
|
||||
"sdk_migration_coding_inventory_failed",
|
||||
"sdk_migration_work_pending",
|
||||
"sdk_migration_cutover_failed",
|
||||
"sdk_migration_bundle_copy_failed",
|
||||
"sdk_migration_response_invalid",
|
||||
"sdk_migration_status_unavailable",
|
||||
"sdk_migration_incomplete",
|
||||
]);
|
||||
|
||||
export function stableRuntimeErrorCode(error: unknown): string {
|
||||
@@ -208,34 +191,6 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
|
||||
"已安装的运行环境比这个安装包更新,无法用旧安装包启动。请安装最新版本的 MemHub;你的记忆数据不会受到影响。",
|
||||
install_current_unmanaged:
|
||||
"运行环境的安装目录被改动过,无法安全接管。请重新安装 MemHub,你的记忆数据不会受到影响。",
|
||||
model_profile_not_active:
|
||||
"还没有配置可用的模型,无法升级记忆数据格式。请在旧版本中完成模型配置后重启;你的记忆数据不会受到影响。",
|
||||
model_secret_unavailable:
|
||||
"模型密钥读不到(可能被系统钥匙串拒绝)。请允许 MemHub 访问钥匙串后重新启动;你的记忆数据不会受到影响。",
|
||||
sdk_migration_disk_space_insufficient:
|
||||
"磁盘空间不足,升级记忆数据格式时中断了。请至少腾出与记忆数据同等大小的可用空间(可在「关于本机 → 储存空间」查看),然后重新启动 MemHub;升级会自动继续,原有记忆数据不会丢失。",
|
||||
sdk_migration_staging_failed:
|
||||
"升级准备工作没有完成,通常是磁盘空间不足或文件被占用。请清理磁盘空间后重新启动;原有记忆数据不会丢失。",
|
||||
sdk_migration_bundle_copy_failed:
|
||||
"升级前备份没有写完,通常是磁盘空间不足。请清理磁盘空间后重新启动;原有记忆数据不会丢失。",
|
||||
sdk_migration_working_fact_conflict:
|
||||
"旧版数据里有一条 Coding 记忆的记录类型在升级中无法安全对应。为避免写错你的记忆,升级已停止。请把这个提示反馈给我们;原有记忆数据不会丢失。",
|
||||
sdk_migration_coding_inventory_failed:
|
||||
"读取旧版 Coding 记忆时发现无法识别的结构,升级已停止。请把这个提示反馈给我们;原有记忆数据不会丢失。",
|
||||
sdk_migration_backfill_failed:
|
||||
"升级记忆数据格式时失败,已停止且没有切换数据源。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
|
||||
sdk_migration_work_pending:
|
||||
"记忆数据已经升级完成,但还有之前没处理完的记忆任务,所以暂时不能切换数据源。请保持 MemHub 打开几分钟,等这些任务处理完再重新启动;原有记忆数据不会丢失。",
|
||||
sdk_migration_cutover_failed:
|
||||
"切换数据源时失败,原有记忆数据仍然完整可用。请重新启动重试;如果仍然失败,请把这个提示反馈给我们。",
|
||||
sdk_migration_preflight_failed:
|
||||
"升级前的检查没有通过,升级已停止。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
|
||||
sdk_migration_status_unavailable:
|
||||
"读不到升级状态,无法判断是否需要升级。请重新启动 MemHub;如果仍然失败,请重新安装,你的记忆数据不会受到影响。",
|
||||
sdk_migration_response_invalid:
|
||||
"升级过程返回了无法识别的内容,已停止。原有记忆数据不会丢失;请重新启动重试,如果仍然失败,请把这个提示反馈给我们。",
|
||||
sdk_migration_incomplete:
|
||||
"记忆数据格式升级没有完成,MemHub 暂时无法打开。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
|
||||
desktop_start_failed:
|
||||
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
|
||||
};
|
||||
|
||||
@@ -21,21 +21,6 @@ describe("startup screen", () => {
|
||||
);
|
||||
expect(spanish).not.toMatch(/[一-鿿]/);
|
||||
});
|
||||
it("says an upgrade may take a while instead of leaving a silent wait", () => {
|
||||
expect(startupScreenHtml()).toContain(
|
||||
"首次启动或升级记忆数据可能需要几分钟,完成后会自动进入应用。",
|
||||
);
|
||||
expect(startupScreenHtml("en")).toContain(
|
||||
"The first launch or a memory-data upgrade can take a few minutes.",
|
||||
);
|
||||
});
|
||||
it("keeps a draggable strip so the waiting window can be moved", () => {
|
||||
const html = startupScreenHtml();
|
||||
// CSS alone does not move a frameless window: the region has to be a real
|
||||
// element in the document, and it has to be the top strip.
|
||||
expect(html).toContain('<div class="window-drag-region"');
|
||||
expect(html).toContain("-webkit-app-region: drag");
|
||||
});
|
||||
it("is served from the app origin, not from the web bundle", () => {
|
||||
expect(STARTUP_SCREEN_URL).toBe("one-memory://app/__startup");
|
||||
});
|
||||
|
||||
@@ -37,19 +37,6 @@ export function startupScreenHtml(locale: GuiLocale = "zh-CN"): string {
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* A frameless window still needs somewhere to grab; without this the
|
||||
whole wait is undraggable. */
|
||||
.window-drag-region {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 58px;
|
||||
-webkit-app-region: drag;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #15181d;
|
||||
@@ -74,13 +61,6 @@ export function startupScreenHtml(locale: GuiLocale = "zh-CN"): string {
|
||||
font-size: 13px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
p.hint {
|
||||
max-width: 320px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
text-align: center;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.bar {
|
||||
position: relative;
|
||||
width: 168px;
|
||||
@@ -121,11 +101,9 @@ export function startupScreenHtml(locale: GuiLocale = "zh-CN"): string {
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="window-drag-region" aria-hidden="true"></div>
|
||||
<main>
|
||||
<h1>MemHub</h1>
|
||||
<p>${translate(locale, "startupPreparing")}</p>
|
||||
<p class="hint">${translate(locale, "startupUpgradeHint")}</p>
|
||||
<div class="bar"><span></span></div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -1,38 +1,13 @@
|
||||
import { access, mkdtemp, readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import {
|
||||
requireCompletedStartupSdkMigration,
|
||||
runStartupSdkMigration,
|
||||
withSdkMigrationCaptureFence,
|
||||
} from "./startup-sdk-migration";
|
||||
|
||||
const core = { command: "/core/one-memory", args: [] };
|
||||
const captureFence = async <T>(
|
||||
_dataDir: string,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> => action();
|
||||
|
||||
it("fences hook capture only while startup migration is running", async () => {
|
||||
const dataDir = await mkdtemp(
|
||||
path.join(os.tmpdir(), "memhub-sdk-migration-"),
|
||||
);
|
||||
const marker = path.join(dataDir, ".sdk-migration-capture-fence.json");
|
||||
|
||||
await withSdkMigrationCaptureFence(dataDir, async () => {
|
||||
const document = JSON.parse(await readFile(marker, "utf8")) as {
|
||||
pid: number;
|
||||
};
|
||||
expect(document.pid).toBe(process.pid);
|
||||
});
|
||||
|
||||
await expect(access(marker)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("runs bundle backfill and cutover inside the startup maintenance fence", async () => {
|
||||
const calls: string[][] = [];
|
||||
const ordering: string[] = [];
|
||||
const execute = vi.fn(async (_core, args: string[]) => {
|
||||
calls.push(args);
|
||||
const command = args[0];
|
||||
@@ -58,19 +33,7 @@ it("runs bundle backfill and cutover inside the startup maintenance fence", asyn
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
fenceCalls.push(dataDir);
|
||||
ordering.push("runtime-fence:start");
|
||||
const result = await action();
|
||||
ordering.push("runtime-fence:end");
|
||||
return result;
|
||||
};
|
||||
const orderedCaptureFence = async <T>(
|
||||
_dataDir: string,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
ordering.push("capture-fence:start");
|
||||
const result = await action();
|
||||
ordering.push("capture-fence:end");
|
||||
return result;
|
||||
return action();
|
||||
};
|
||||
|
||||
const result = await runStartupSdkMigration({
|
||||
@@ -79,17 +42,10 @@ it("runs bundle backfill and cutover inside the startup maintenance fence", asyn
|
||||
runtimeBuild: "build-13",
|
||||
execute,
|
||||
fence,
|
||||
captureFence: orderedCaptureFence,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ state: "cutover" });
|
||||
expect(fenceCalls).toEqual(["/data"]);
|
||||
expect(ordering).toEqual([
|
||||
"capture-fence:start",
|
||||
"runtime-fence:start",
|
||||
"runtime-fence:end",
|
||||
"capture-fence:end",
|
||||
]);
|
||||
expect(calls.map((args) => args[0])).toEqual([
|
||||
"sdk-migration-status",
|
||||
"migration-bundle",
|
||||
@@ -117,7 +73,6 @@ it("does not stop Runtime when authority is already SDK or the home is empty", a
|
||||
runtimeBuild: "build-13",
|
||||
execute: vi.fn(async () => ({ exitCode: 0, document })),
|
||||
fence,
|
||||
captureFence,
|
||||
});
|
||||
expect(result).toEqual({ state: "not_required" });
|
||||
expect(fenced).toBe(false);
|
||||
@@ -131,7 +86,6 @@ it("reports an unreadable migration status as deferred", async () => {
|
||||
runtimeBuild: "build-13",
|
||||
execute: vi.fn(async () => ({ exitCode: 1, document: {} })),
|
||||
fence: async (_dataDir, action) => action(),
|
||||
captureFence,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -162,7 +116,6 @@ it("resumes legacy table cleanup under the maintenance fence", async () => {
|
||||
: { exitCode: 0, document: { state: "clean" } };
|
||||
}),
|
||||
fence: async (_dataDir, action) => action(),
|
||||
captureFence,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ state: "cutover" });
|
||||
@@ -193,7 +146,6 @@ it("keeps legacy authority when backfill is blocked", async () => {
|
||||
runtimeBuild: "build-13",
|
||||
execute,
|
||||
fence: async (_dataDir, action) => action(),
|
||||
captureFence,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ state: "deferred", code: "quarantine" });
|
||||
@@ -201,42 +153,3 @@ it("keeps legacy authority when backfill is blocked", async () => {
|
||||
"sdk-cutover",
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses one v2 migration identity after a blocked startup", async () => {
|
||||
const calls: string[][] = [];
|
||||
const migrationId = "sdk-authority-v2-0123456789abcdef";
|
||||
const bundleId = `${migrationId}-pre-cutover`;
|
||||
const execute = vi.fn(async (_core, args: string[]) => {
|
||||
calls.push(args);
|
||||
return args[0] === "sdk-migration-status"
|
||||
? {
|
||||
exitCode: 0,
|
||||
document: {
|
||||
authority: "legacy",
|
||||
state: "required",
|
||||
generation: "ffffffffffffffff",
|
||||
migration_id: migrationId,
|
||||
bundle_id: bundleId,
|
||||
},
|
||||
}
|
||||
: args[0] === "sdk-migrate"
|
||||
? { exitCode: 4, document: { state: "blocked", code: "quarantine" } }
|
||||
: { exitCode: 0, document: { state: "reused" } };
|
||||
});
|
||||
|
||||
await runStartupSdkMigration({
|
||||
dataDir: "/data",
|
||||
core,
|
||||
runtimeBuild: "build-17",
|
||||
execute,
|
||||
fence: async (_dataDir, action) => action(),
|
||||
captureFence,
|
||||
});
|
||||
|
||||
expect(calls.find((args) => args[0] === "migration-bundle")).toContain(
|
||||
bundleId,
|
||||
);
|
||||
expect(calls.find((args) => args[0] === "sdk-migrate")).toContain(
|
||||
migrationId,
|
||||
);
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { withRuntimeMaintenanceFence } from "@one-memory/runtime-control";
|
||||
|
||||
const MIGRATION_PREFIX = "sdk-authority-v2";
|
||||
const MIGRATION_PREFIX = "sdk-authority-v1";
|
||||
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
const CAPTURE_FENCE = ".sdk-migration-capture-fence.json";
|
||||
|
||||
export interface StartupMigrationResult {
|
||||
state: "not_required" | "cutover" | "deferred";
|
||||
@@ -42,7 +39,6 @@ interface StartupMigrationOptions {
|
||||
dataDir: string,
|
||||
) => Promise<CommandResult>;
|
||||
fence?: <T>(dataDir: string, action: () => Promise<T>) => Promise<T>;
|
||||
captureFence?: <T>(dataDir: string, action: () => Promise<T>) => Promise<T>;
|
||||
}
|
||||
|
||||
export async function runStartupSdkMigration(
|
||||
@@ -50,7 +46,6 @@ export async function runStartupSdkMigration(
|
||||
): Promise<StartupMigrationResult> {
|
||||
const execute = options.execute ?? executeCoreCommand;
|
||||
const fence = options.fence ?? withRuntimeMaintenanceFence;
|
||||
const captureFence = options.captureFence ?? withSdkMigrationCaptureFence;
|
||||
const status = await execute(
|
||||
options.core,
|
||||
["sdk-migration-status", "--json"],
|
||||
@@ -63,18 +58,16 @@ export async function runStartupSdkMigration(
|
||||
status.document.authority === "sdk" &&
|
||||
status.document.state === "cleanup_required"
|
||||
) {
|
||||
return captureFence(options.dataDir, () =>
|
||||
fence(options.dataDir, async () => {
|
||||
const cleanup = await execute(
|
||||
options.core,
|
||||
["sdk-cleanup", "--yes", "--json"],
|
||||
options.dataDir,
|
||||
);
|
||||
return cleanup.exitCode === 0 && cleanup.document.state === "clean"
|
||||
? { state: "cutover" }
|
||||
: deferred(cleanup);
|
||||
}),
|
||||
);
|
||||
return fence(options.dataDir, async () => {
|
||||
const cleanup = await execute(
|
||||
options.core,
|
||||
["sdk-cleanup", "--yes", "--json"],
|
||||
options.dataDir,
|
||||
);
|
||||
return cleanup.exitCode === 0 && cleanup.document.state === "clean"
|
||||
? { state: "cutover" }
|
||||
: deferred(cleanup);
|
||||
});
|
||||
}
|
||||
if (
|
||||
status.document.authority === "sdk" ||
|
||||
@@ -86,89 +79,57 @@ export async function runStartupSdkMigration(
|
||||
if (typeof generation !== "string" || !/^[a-f0-9]{16}$/.test(generation)) {
|
||||
return { state: "deferred", code: "sdk_migration_generation_invalid" };
|
||||
}
|
||||
const previousMigrationId = status.document.migration_id;
|
||||
const previousBundleId = status.document.bundle_id;
|
||||
const canResume =
|
||||
typeof previousMigrationId === "string" &&
|
||||
previousMigrationId.startsWith(`${MIGRATION_PREFIX}-`) &&
|
||||
typeof previousBundleId === "string" &&
|
||||
previousBundleId === `${previousMigrationId}-pre-cutover`;
|
||||
const migrationId = canResume
|
||||
? previousMigrationId
|
||||
: `${MIGRATION_PREFIX}-${generation}`;
|
||||
const bundleId = canResume ? previousBundleId : `${migrationId}-pre-cutover`;
|
||||
return captureFence(options.dataDir, () =>
|
||||
fence(options.dataDir, async () => {
|
||||
const bundle = await execute(
|
||||
options.core,
|
||||
[
|
||||
"migration-bundle",
|
||||
"--bundle-id",
|
||||
bundleId,
|
||||
"--runtime-build",
|
||||
options.runtimeBuild,
|
||||
"--yes",
|
||||
"--json",
|
||||
],
|
||||
options.dataDir,
|
||||
);
|
||||
if (bundle.exitCode !== 0) return deferred(bundle);
|
||||
const backfill = await execute(
|
||||
options.core,
|
||||
[
|
||||
"sdk-migrate",
|
||||
"--migration-id",
|
||||
migrationId,
|
||||
"--bundle-id",
|
||||
bundleId,
|
||||
"--yes",
|
||||
"--json",
|
||||
],
|
||||
options.dataDir,
|
||||
);
|
||||
if (backfill.exitCode !== 0 || backfill.document.state !== "ready") {
|
||||
return deferred(backfill);
|
||||
}
|
||||
const cutover = await execute(
|
||||
options.core,
|
||||
[
|
||||
"sdk-cutover",
|
||||
"--migration-id",
|
||||
migrationId,
|
||||
"--bundle-id",
|
||||
bundleId,
|
||||
"--yes",
|
||||
"--json",
|
||||
],
|
||||
options.dataDir,
|
||||
);
|
||||
if (cutover.exitCode !== 0 || cutover.document.state !== "cutover") {
|
||||
return deferred(cutover);
|
||||
}
|
||||
return { state: "cutover" };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function withSdkMigrationCaptureFence<T>(
|
||||
dataDir: string,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
||||
const marker = path.join(dataDir, CAPTURE_FENCE);
|
||||
const temporary = `${marker}.${process.pid}.tmp`;
|
||||
await writeFile(
|
||||
temporary,
|
||||
`${JSON.stringify({ schema_version: 1, pid: process.pid })}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
await rename(temporary, marker);
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
await rm(marker, { force: true });
|
||||
await rm(temporary, { force: true });
|
||||
}
|
||||
const migrationId = `${MIGRATION_PREFIX}-${generation}`;
|
||||
const bundleId = `${migrationId}-pre-cutover`;
|
||||
return fence(options.dataDir, async () => {
|
||||
const bundle = await execute(
|
||||
options.core,
|
||||
[
|
||||
"migration-bundle",
|
||||
"--bundle-id",
|
||||
bundleId,
|
||||
"--runtime-build",
|
||||
options.runtimeBuild,
|
||||
"--yes",
|
||||
"--json",
|
||||
],
|
||||
options.dataDir,
|
||||
);
|
||||
if (bundle.exitCode !== 0) return deferred(bundle);
|
||||
const backfill = await execute(
|
||||
options.core,
|
||||
[
|
||||
"sdk-migrate",
|
||||
"--migration-id",
|
||||
migrationId,
|
||||
"--bundle-id",
|
||||
bundleId,
|
||||
"--yes",
|
||||
"--json",
|
||||
],
|
||||
options.dataDir,
|
||||
);
|
||||
if (backfill.exitCode !== 0 || backfill.document.state !== "ready") {
|
||||
return deferred(backfill);
|
||||
}
|
||||
const cutover = await execute(
|
||||
options.core,
|
||||
[
|
||||
"sdk-cutover",
|
||||
"--migration-id",
|
||||
migrationId,
|
||||
"--bundle-id",
|
||||
bundleId,
|
||||
"--yes",
|
||||
"--json",
|
||||
],
|
||||
options.dataDir,
|
||||
);
|
||||
if (cutover.exitCode !== 0 || cutover.document.state !== "cutover") {
|
||||
return deferred(cutover);
|
||||
}
|
||||
return { state: "cutover" };
|
||||
});
|
||||
}
|
||||
|
||||
function deferred(result: CommandResult): StartupMigrationResult {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/site",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/web",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -130,7 +130,6 @@ This exception does not extend to VDB, Coding, prompt, reconcile, or lifecycle.
|
||||
|
||||
- Storage backends:仅 SDK config/env(Chroma/FAISS/Qdrant、Kuzu/Neo4j、cache/history sqlite|mysql、coding sqlite 等)。
|
||||
- Queue:pending `memory_write_jobs` 只表示尚未调用 SDK 的待处理请求,因此没有 SDK raw/memory ID。worker 单次调用 SDK `add()`;SDK 内部完成 raw 写入与 extraction/reconciliation,成功后 MemHub 仅将返回的 raw ID / memory IDs 记作 job outcome。旧用户启动迁移在 Runtime writers 停止期间直接操作 SDK 存储,不向公开 `HyMemoryClient` 增加迁移专用方法,也不拆分正常写路径。
|
||||
- Raw retention:SDK L1 raw 只保留最近三天;每个完整 turn 只保存真人 user 与 assistant,tool 不进入 raw。高层 Chat/Coding/Working memory 不受该窗口影响。
|
||||
- Recall:Host Bridge / Core 先做宿主安装凭据与 tool grant,再调 **SDK search/get**。当前产品没有逐 memory 的 A-harness→B-harness 可见性开关,默认允许跨 harness 搜索;未来若增加来源限制,直接透传 SDK `source` / `include_sources` / `exclude_sources`,不在 MemHub 建 memory ACL。
|
||||
- Context wire:cutover marker 提交前只使用 legacy Context v1;提交后 Host Bridge 只使用 SDK-native Context v2。两种 schema 原样校验、原样返回,不把 SDK item 补造成 v1 的 `revision`、`evidence_ids`、`paths` 或 `adoption`,已选择的 v2 路径失败时也不回退 legacy 权威。
|
||||
- Ask wire:marker 前保留 Governance Ask v1;marker 后 Web / Host Bridge 只调用 SDK Ask v2。v2 的引用只含 SDK 原生 `memory_id/content/layer/status/source/gmt_created`,生成过程中重复 `get` 校验同一 SDK 快照,不补造 revision、Evidence 或 scopes,SDK 失败不回退 v1。
|
||||
@@ -139,8 +138,6 @@ This exception does not extend to VDB, Coding, prompt, reconcile, or lifecycle.
|
||||
- SDK backfill 只写同文件系统的私有 staging 副本;验证后先原子切换 SDK
|
||||
root,再提交 authority marker。切换或 marker 提交失败必须恢复原 live root;
|
||||
App 不得在 migration `deferred` 状态继续启动 Runtime。
|
||||
- 迁移期间宿主 capture hook 由 Desktop 活跃 PID marker 静默挡住,不再追加
|
||||
Evidence;cutover 后先从宿主 History checkpoint 补齐遗漏 turn,再恢复 live sync。
|
||||
- Matter 若未来重新开发,需以新的 SDK-native 合同重新设计,不继承当前占位 schema。
|
||||
|
||||
Pinned SDK 版本见发布锁文件 / `MEMORY.md`(随发布更新)。
|
||||
@@ -285,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
|
||||
- Linux x64/arm64 Runtime on native GHA runners.
|
||||
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
|
||||
- Windows is not supported.
|
||||
- `hy-memory==2.0.5` is the current stable SDK dependency; the release
|
||||
- `hy-memory==2.0.5.dev3` is the current development-preview SDK dependency; the release
|
||||
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
|
||||
|
||||
See [`INSTALLATION.md`](INSTALLATION.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MemHub 安装与发行
|
||||
|
||||
MemHub `v1.0.5`(build 14)的发行目标如下:
|
||||
MemHub `v1.0.4`(build 13)的发行目标如下:
|
||||
|
||||
| 平台 | 发行物 | 构建与验收 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -6,13 +6,10 @@
|
||||
2. 记忆沉淀有没有持续运行、产出了多少条;
|
||||
3. 如果停住,停在导入、模型调用还是最终写入。
|
||||
|
||||
默认 `info` 下的官方结构化事件不应写入会话正文、模型输入输出或 API Key;但本地原始日志允许
|
||||
新增受限标量标签,仍应按私人数据处理,不能直接对外发送。`debug` 会额外启用 HY pipeline
|
||||
trace,其中可能包含完整 prompt/response 和获授权的对话正文。
|
||||
|
||||
排查时只通过 App 的“导出日志”提供文件:导出过程会按安全白名单重新生成诊断事件,不会打包
|
||||
原始日志。不要直接发送 `diagnostic-*.jsonl`、整个 `~/.one-memory` 数据目录或 debug pipeline
|
||||
日志;后者如确需分享,必须由开发人员另行确认并脱敏。
|
||||
默认 `info` 下的结构化诊断日志不包含会话正文、模型输入输出或 API Key。`debug`
|
||||
会额外启用 HY pipeline trace,其中可能包含完整 prompt/response 和获授权的对话正文。
|
||||
排查时优先通过 App 的“导出日志”提供文件,不要直接发送整个 `~/.one-memory` 数据目录;
|
||||
debug 期间产生的 pipeline 日志在分享前必须由开发人员确认并脱敏。
|
||||
|
||||
## 1. Log level 设什么
|
||||
|
||||
@@ -42,8 +39,7 @@ debug 可能把完整模型 prompt/response 写入本机调试日志,不得长
|
||||
## 2. 日志在哪里
|
||||
|
||||
App 内推荐直接使用“运行诊断 → 导出日志”。开发人员远程指导时,优先使用
|
||||
`memhub logs`,它会再次过滤不安全字段。原始结构化日志只用于本机现场查看,不得作为附件发送。
|
||||
Core 日志默认位于:
|
||||
`memhub logs`,它会再次过滤不安全字段。需要现场查看原始结构化日志时,Core 日志默认位于:
|
||||
|
||||
```text
|
||||
~/.one-memory/logs/diagnostic-core.jsonl
|
||||
|
||||
@@ -15,17 +15,9 @@ Desktop -> Runtime -> Core -> HY -> Host 的实际执行链。日志不是用户
|
||||
可选关联字段为 `trace_id`、`incident_id`、`instance_id`、`operation_id`、`request_id`、
|
||||
`job_id`、`task_id`、`duration_ms`、状态、稳定错误码、错误类型和清理后的仓库相对栈帧。
|
||||
|
||||
`details` 的本地写入合同只约束结构,不维护业务字段白名单:最多 32 个 key;key 必须是
|
||||
1–64 字符的字母、数字、点、下划线或连字符并以字母开头;value 只接受 `null`、布尔值、
|
||||
跨 Python/JavaScript 可无损表示的有限数字(整数限于 ±(2^53-1))或不超过 256 字符且不含
|
||||
控制换行的短字符串,不接受对象和数组。这样新增诊断标签
|
||||
不需要先修改中央白名单。诊断日志的校验、序列化、排队或落盘发生任何异常时都只能丢弃该条
|
||||
日志,不能阻断产品主链。
|
||||
|
||||
本地写入放宽不代表允许官方代码记录用户内容。对话原文、查询、回答、模型输入输出、请求体、
|
||||
响应体、token、API Key、secret ref、完整用户路径和异常 message/cause 仍禁止进入诊断事件,
|
||||
由类型、静态检查和隐私测试共同防止误用;确需保存完整用户输入的调试能力必须使用独立的本地
|
||||
trace/session 文件,不得混入可导出的诊断日志。
|
||||
`details` 只接受代码声明的标量白名单。禁止记录对话原文、查询、回答、模型输入输出、
|
||||
请求体、响应体、token、API Key、secret ref、完整用户路径或异常 message/cause。未知字段和
|
||||
不合规标识拒绝写入。日志写入失败不能阻断产品主链。
|
||||
|
||||
## 关联规则
|
||||
|
||||
@@ -42,16 +34,9 @@ trace/session 文件,不得混入可导出的诊断日志。
|
||||
host-bridge、desktop 和 cli。单文件 4 MiB,保留 3 份轮转;额外清理 14 天以前的文件并将
|
||||
总量限制在 128 MiB。POSIX 目录为 0700、文件为 0600。
|
||||
|
||||
读取器只枚举上述固定文件名的 regular file,不跟随符号链接,不接受任意路径。日常本地查询
|
||||
遇到损坏行时跳过并计数,不让一行损坏遮蔽其余证据。
|
||||
|
||||
导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
|
||||
新的 `diagnostics/events.jsonl`;details 不只检查 key,还按字段约束 value 类型,并拒绝自由文本、
|
||||
路径和凭证形态的字符串。绝不复制原始 `diagnostic-*.jsonl`、普通运行日志或崩溃报告。
|
||||
导出包还可以包含固定 schema 的系统摘要和问题时间点,但不接受调用方传入的任意对象展开。
|
||||
任何事件、marker、安全投影或日志目录读取失败时,导出必须 fail-closed:删除临时文件、不发布
|
||||
诊断包,更不能回退为打包原始日志。发布使用已设好权限的临时文件原子创建最终目标,已存在的
|
||||
目标不得覆盖,发布后也不存在再 chmod 失败留下伪成功文件的窗口。
|
||||
读取器只枚举上述固定文件名的 regular file,不跟随符号链接,不接受任意路径。损坏行跳过
|
||||
并计数,不让一行损坏遮蔽其余证据。诊断导出是单个脱敏 JSON,包含版本、平台、架构、
|
||||
非秘密状态和最多 5000 条已验证事件,不包含数据库或配置文件。
|
||||
|
||||
## 覆盖与节流
|
||||
|
||||
@@ -71,8 +56,7 @@ host-bridge、desktop 和 cli。单文件 4 MiB,保留 3 份轮转;额外清
|
||||
|
||||
## 验收
|
||||
|
||||
隐私测试必须先向本地日志植入带未知 key 的可检测秘密作为正向对照,再证明本地读取仍保留该
|
||||
证据、导出安全投影必定剔除它;另需证明损坏行会阻止导出且不会留下伪成功文件。至少用隔离
|
||||
数据制造 Core 启动失败、HY 失败和宿主连接失败,并仅凭允许导出的字段还原失败组件、时间、
|
||||
隐私测试必须先植入一条可检测的秘密作为正向对照,再证明真实链路日志和导出均不含该秘密。
|
||||
至少用隔离数据制造 Core 启动失败、HY 失败和宿主连接失败,并仅凭日志还原失败组件、时间、
|
||||
关联 ID、稳定错误码和代码位置。自动测试通过只能标记 verified,正式安装 App 的真实体验仍需
|
||||
单独验收。
|
||||
@@ -19,9 +19,6 @@ Before the cutover marker is committed:
|
||||
- SDK stores are inspected and backfilled, but are not merged into live search;
|
||||
- new imports, writes, queue drains, corrections, deletes and session delivery
|
||||
are paused.
|
||||
- Host capture commands detect the live Desktop migration fence and return
|
||||
silently without writing Evidence. After cutover, History catch-up runs from
|
||||
the existing source cursor before ordinary live sync.
|
||||
|
||||
After the cutover marker is committed:
|
||||
|
||||
@@ -52,9 +49,12 @@ The migration preflight records and validates:
|
||||
- embedding model identity and dimensions;
|
||||
- source grants, model profiles, config, queue leases, and pending operations.
|
||||
|
||||
The migration fails closed when it cannot inspect every SDK memory namespace
|
||||
(Chat, Coding Experience, Working Fact), write a missing ordinary memory, or
|
||||
create and restore a consistent data-plane snapshot.
|
||||
The migration fails closed when the installed SDK lacks:
|
||||
|
||||
- complete lifecycle/raw inventory;
|
||||
- exact raw and exact memory import;
|
||||
- durable request outcome lookup;
|
||||
- consistent snapshot/export and restore.
|
||||
|
||||
MemHub tags `v1.0.0` through `v1.0.4` all use the `governed-v2` namespace and
|
||||
the same SDK SQL table families (`memory_operations_v2`, `pipeline_logs_v2`,
|
||||
@@ -71,10 +71,10 @@ because an earlier App wrote it.
|
||||
|
||||
While Core and Runtime writers are stopped, build four inventories:
|
||||
|
||||
- `E`: completed user/assistant turns from the rolling three-day legacy window;
|
||||
- `E`: Evidence rows and revisions;
|
||||
- `G`: current Governance rows, statuses, supersession links, and latest content;
|
||||
- `M`: task-output mappings from legacy `memory_id` to SDK `hy_memory_id`;
|
||||
- `S`: complete SDK inventory, including Coding/Working and non-active records.
|
||||
- `S`: complete SDK inventory, including raw and non-active lifecycle records.
|
||||
|
||||
Every item must end in exactly one class:
|
||||
|
||||
@@ -85,7 +85,7 @@ Every item must end in exactly one class:
|
||||
| Governance-only | current legacy memory has no SDK entity | exact import without extraction |
|
||||
| SDK-only | SDK entity has no legacy mirror | retain and adopt |
|
||||
| divergent | both exist but content, owner or lifecycle differs | reconcile explicit user intent or quarantine |
|
||||
| raw turn missing | canonical recent turn has no matching SDK raw | write one bounded L1 raw turn |
|
||||
| raw-only | Evidence has no SDK raw receipt | exact raw import |
|
||||
| unknown outcome | a task may have reached SDK without a confirmed result | SDK outcome inquiry; never blind retry |
|
||||
|
||||
An unclassified item blocks cutover.
|
||||
@@ -102,30 +102,26 @@ The current product has no MemHub revision history or Undo.
|
||||
- SDK supersession remains an SDK lifecycle relationship. Existing SDK chains
|
||||
are retained. Governance links are used only to repair a missing SDK chain
|
||||
during exact import.
|
||||
- Mapped Coding Experience and Working Fact IDs are resolved in their native
|
||||
SDK stores and adopted without conversion to Chat layers. A mapped ID missing
|
||||
from its native store blocks cutover rather than fabricating another type.
|
||||
- Pending correction/delete records are settled before ordinary backfill.
|
||||
|
||||
`update(memory_id, content)` modifies the same SDK ID. There is no product CAS,
|
||||
revision number, or restore operation.
|
||||
|
||||
## 5. Legacy Evidence to bounded SDK raw
|
||||
## 5. Evidence to SDK raw
|
||||
|
||||
Evidence rows are message-level legacy storage, not SDK raw chunks. Migration
|
||||
uses them only to rebuild completed turns from the three days preceding the
|
||||
stable migration start time:
|
||||
Evidence must not be replayed through ordinary `add()`: doing so would extract
|
||||
and reconcile again, changing semantic memories.
|
||||
|
||||
- one human `user` opener plus its following `assistant` records becomes one
|
||||
deterministic L1 raw node;
|
||||
- tool rows and agent-authored user envelopes are excluded;
|
||||
- a turn is bounded to 60,000 characters and records whether it was truncated;
|
||||
- existing SDK raw outside this canonical set is removed in staging;
|
||||
- high-level Chat/Coding/Working memories are retained independently of raw.
|
||||
The SDK exact-raw import preserves:
|
||||
|
||||
No extraction or reconciliation is rerun while rebuilding raw. After cutover,
|
||||
the normal Cursor History catch-up scans forward from its source checkpoint and
|
||||
recovers turns skipped while capture was fenced.
|
||||
- source, conversation/session, workspace, role and author;
|
||||
- original event time and stable source ordering;
|
||||
- verbatim content and source identity;
|
||||
- a deterministic migration key.
|
||||
|
||||
The operation is idempotent and returns an SDK raw ID. Evidence revisions and
|
||||
repairs are preserved in the migration bundle for audit; only the current
|
||||
accepted raw record becomes the normal product read.
|
||||
|
||||
## 6. Full migration bundle
|
||||
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "one-memory",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"release": {
|
||||
"build_number": 17,
|
||||
"build_number": 13,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/contracts",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/host-bridge",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -192,12 +192,16 @@ describe("RecallBroker", () => {
|
||||
level: "error",
|
||||
component: "host.broker",
|
||||
outcome: "failed",
|
||||
error_code: "connection_control_unavailable",
|
||||
error_type: "Error",
|
||||
details: { action: "host" },
|
||||
details: expect.objectContaining({
|
||||
action: "host",
|
||||
code: "connection_control_unavailable",
|
||||
error_type: "Error",
|
||||
}),
|
||||
});
|
||||
expect(exception?.details).not.toHaveProperty("message");
|
||||
expect(exception?.details).not.toHaveProperty("stack_origin");
|
||||
expect(typeof exception?.details).toBe("object");
|
||||
const details = exception?.details as Record<string, unknown>;
|
||||
expect(typeof details.stack_origin).toBe("string");
|
||||
expect(details.stack_origin).toContain("recall-broker");
|
||||
});
|
||||
it.each(["recall", "ask"])(
|
||||
"answers %s without waiting for a slow diagnostic write",
|
||||
|
||||
@@ -702,10 +702,20 @@ export class RecallBroker {
|
||||
event: "connection_control.exception",
|
||||
outcome: "failed",
|
||||
instance_id: this.options.instanceId,
|
||||
error_code: code,
|
||||
error_type:
|
||||
error instanceof Error ? error.constructor.name : typeof error,
|
||||
details: { action: action ?? "invalid" },
|
||||
details: {
|
||||
action: action ?? "invalid",
|
||||
code,
|
||||
error_type:
|
||||
error instanceof Error ? error.constructor.name : typeof error,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message.slice(0, 256)
|
||||
: String(error).slice(0, 256),
|
||||
stack_origin:
|
||||
error instanceof Error && error.stack
|
||||
? (error.stack.split("\n").at(1)?.trim() ?? "").slice(0, 256)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
const fingerprint = `error:${code}`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/i18n",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -1933,8 +1933,6 @@ const baseEnglishMessages = {
|
||||
languageLoadError: "Could not load the language setting. Please retry.",
|
||||
nativeErrorTitle: "MemHub could not start",
|
||||
startupPreparing: "Starting and preparing the memory service…",
|
||||
startupUpgradeHint:
|
||||
"The first launch or a memory-data upgrade can take a few minutes. MemHub opens automatically when it is done.",
|
||||
} as const;
|
||||
|
||||
export const englishMessages = {
|
||||
|
||||
@@ -52,8 +52,6 @@ const languageChineseMessages = {
|
||||
languageLoadError: "无法加载语言设置,请重试。",
|
||||
nativeErrorTitle: "MemHub 无法启动",
|
||||
startupPreparing: "正在启动,正在准备记忆服务…",
|
||||
startupUpgradeHint:
|
||||
"首次启动或升级记忆数据可能需要几分钟,完成后会自动进入应用。",
|
||||
} as const;
|
||||
|
||||
const languageSpanishMessages: Record<
|
||||
@@ -73,8 +71,6 @@ const languageSpanishMessages: Record<
|
||||
languageLoadError: "No se pudo cargar el idioma. Inténtalo de nuevo.",
|
||||
nativeErrorTitle: "MemHub no pudo iniciarse",
|
||||
startupPreparing: "Iniciando y preparando el servicio de memorias…",
|
||||
startupUpgradeHint:
|
||||
"El primer inicio o la actualización de tus memorias puede tardar unos minutos. La aplicación se abrirá automáticamente al terminar.",
|
||||
};
|
||||
|
||||
type Catalog = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/operations",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/runtime-control",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -680,7 +680,7 @@ export class CoreSupervisor {
|
||||
outcome: "failed",
|
||||
instance_id: this.activeInstanceId,
|
||||
error_code: "core_restart_circuit_open",
|
||||
details: { restart_streak: this.restartStreak },
|
||||
details: { streak: this.restartStreak },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -765,7 +765,7 @@ export class CoreSupervisor {
|
||||
outcome: "failed",
|
||||
instance_id: this.activeInstanceId,
|
||||
error_code: "core_restart_exhausted",
|
||||
details: { attempts: budget, restart_streak: this.restartStreak },
|
||||
details: { attempts: budget, streak: this.restartStreak },
|
||||
});
|
||||
await this.options.diagnostics?.log({
|
||||
level: "error",
|
||||
@@ -774,7 +774,7 @@ export class CoreSupervisor {
|
||||
outcome: "failed",
|
||||
instance_id: this.activeInstanceId,
|
||||
error_code: "core_restart_circuit_open",
|
||||
details: { restart_streak: this.restartStreak },
|
||||
details: { streak: this.restartStreak },
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
@@ -22,10 +21,8 @@ import {
|
||||
diagnosticLogFiles,
|
||||
exportDiagnosticBundle,
|
||||
exportDiagnosticZip,
|
||||
projectDiagnosticEventForSafeOutput,
|
||||
pruneDiagnosticLogs,
|
||||
readDiagnosticEvents,
|
||||
recordDiagnosticMarker,
|
||||
writeDiagnosticEventSync,
|
||||
} from "./diagnostic-log.js";
|
||||
|
||||
@@ -69,30 +66,6 @@ describe("diagnostic log", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the synchronous writer fail-open for invalid input", async () => {
|
||||
const dataDir = await root();
|
||||
expect(() =>
|
||||
writeDiagnosticEventSync(
|
||||
{
|
||||
dataDir,
|
||||
service: "desktop",
|
||||
sessionId: "55555555-5555-4555-8555-555555555555",
|
||||
},
|
||||
{
|
||||
level: "error",
|
||||
component: "desktop.lifecycle",
|
||||
event: "desktop.early_start_failed",
|
||||
outcome: "failed",
|
||||
details: { nested: ["private"] } as never,
|
||||
},
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(await readDiagnosticEvents(dataDir)).toEqual({
|
||||
events: [],
|
||||
invalid_lines: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes structured, private events and reads them by correlation identity", async () => {
|
||||
const dataDir = await root();
|
||||
const session = "11111111-1111-4111-8111-111111111111";
|
||||
@@ -116,8 +89,6 @@ describe("diagnostic log", () => {
|
||||
open_file_count: 7,
|
||||
memory_bytes: 4096,
|
||||
thread_count: 4,
|
||||
"cache.hit-rate": 0.75,
|
||||
custom_label: "warm",
|
||||
},
|
||||
});
|
||||
const result = await readDiagnosticEvents(dataDir, { traceId: trace });
|
||||
@@ -130,8 +101,6 @@ describe("diagnostic log", () => {
|
||||
event: "runtime.ready",
|
||||
details: expect.objectContaining({
|
||||
open_file_count: 7,
|
||||
"cache.hit-rate": 0.75,
|
||||
custom_label: "warm",
|
||||
}),
|
||||
});
|
||||
const file = (await diagnosticLogFiles(dataDir))[0];
|
||||
@@ -215,7 +184,7 @@ describe("diagnostic log", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("strictly rejects complex detail values, dangerous keys and absolute stacks", () => {
|
||||
it("rejects arbitrary detail fields and absolute stack paths", () => {
|
||||
const base = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "error" as const,
|
||||
@@ -226,142 +195,11 @@ describe("diagnostic log", () => {
|
||||
session_id: "11111111-1111-4111-8111-111111111111",
|
||||
};
|
||||
expect(() =>
|
||||
diagnosticEvent({
|
||||
...base,
|
||||
details: { nested: { secret: "private" } } as never,
|
||||
}),
|
||||
).toThrow("diagnostic_detail_value_invalid");
|
||||
expect(() =>
|
||||
diagnosticEvent({ ...base, details: { constructor: "private" } }),
|
||||
diagnosticEvent({ ...base, details: { prompt: "private" } }),
|
||||
).toThrow("diagnostic_detail_key_invalid");
|
||||
expect(() =>
|
||||
diagnosticEvent({ ...base, details: { "9invalid": "private" } }),
|
||||
).toThrow("diagnostic_detail_key_invalid");
|
||||
expect(() =>
|
||||
diagnosticEvent({
|
||||
...base,
|
||||
details: Object.fromEntries(
|
||||
Array.from({ length: 33 }, (_, index) => [`key_${index}`, index]),
|
||||
),
|
||||
}),
|
||||
).toThrow("diagnostic_detail_count_invalid");
|
||||
expect(() =>
|
||||
diagnosticEvent({ ...base, details: { message: "line1\nline2" } }),
|
||||
).toThrow("diagnostic_text_invalid");
|
||||
expect(() =>
|
||||
diagnosticEvent({ ...base, details: { metric: Number.NaN } }),
|
||||
).toThrow("diagnostic_detail_value_invalid");
|
||||
expect(() =>
|
||||
diagnosticEvent({
|
||||
...base,
|
||||
details: { count: Number.MAX_SAFE_INTEGER + 1 },
|
||||
}),
|
||||
).toThrow("diagnostic_detail_value_invalid");
|
||||
expect(() =>
|
||||
diagnosticEvent({ ...base, details: { list: ["private"] } as never }),
|
||||
).toThrow("diagnostic_detail_value_invalid");
|
||||
expect(() =>
|
||||
diagnosticEvent({ ...base, safe_stack: ["/Users/me/private.py:1"] }),
|
||||
).toThrow("diagnostic_stack_invalid");
|
||||
expect(() =>
|
||||
diagnosticEvent({
|
||||
...base,
|
||||
safe_stack: ["packages/runtime/sk-live-secret-token.ts:1:1"],
|
||||
}),
|
||||
).toThrow("diagnostic_stack_invalid");
|
||||
for (const line of [
|
||||
"arbitrary stack text",
|
||||
"thread=worker|threading.py:12:wait",
|
||||
"FILE:///Users/me/private.py:1:call",
|
||||
"one_memory/worker.py:9:call /Users/private",
|
||||
"one_memory/GOCSPX-privateCredential.py:9:call",
|
||||
"packages/ASIA1234567890ABCDEF/file.ts:1:1",
|
||||
"packages/Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8/file.ts:1:1",
|
||||
]) {
|
||||
expect(() => diagnosticEvent({ ...base, safe_stack: [line] })).toThrow(
|
||||
"diagnostic_stack_invalid",
|
||||
);
|
||||
}
|
||||
expect(() =>
|
||||
diagnosticEvent({
|
||||
...base,
|
||||
safe_stack: Array.from(
|
||||
{ length: 17 },
|
||||
(_, index) => `one_memory/worker.py:${index + 1}:call`,
|
||||
),
|
||||
}),
|
||||
).toThrow("diagnostic_stack_invalid");
|
||||
|
||||
const accepted = diagnosticEvent({
|
||||
...base,
|
||||
safe_stack: [
|
||||
"apps/desktop/src/main.ts:80:1",
|
||||
"one_memory/diagnostic_log.py:895:_safe_stack_line",
|
||||
"threading.py:1045:_bootstrap_inner",
|
||||
"thread|threading.py:1045:_bootstrap_inner > one_memory/worker.py:9:call",
|
||||
],
|
||||
});
|
||||
expect(accepted.safe_stack).toHaveLength(4);
|
||||
|
||||
const projected = projectDiagnosticEventForSafeOutput({
|
||||
...accepted,
|
||||
safe_stack: [
|
||||
"free text",
|
||||
"thread=worker|one_memory/worker.py:9:call",
|
||||
"packages/runtime-control/src/diagnostic-log.ts:42:7",
|
||||
"thread|threading.py:1045:_bootstrap_inner > one_memory/worker.py:9:call",
|
||||
],
|
||||
});
|
||||
expect(projected.safe_stack).toEqual([
|
||||
"packages/runtime-control/src/diagnostic-log.ts:42:7",
|
||||
"thread|threading.py:1045:_bootstrap_inner > one_memory/worker.py:9:call",
|
||||
]);
|
||||
const conservative = projectDiagnosticEventForSafeOutput(
|
||||
diagnosticEvent({
|
||||
...base,
|
||||
details: {
|
||||
source_id: "cursor",
|
||||
reason: "abcdef1234567890abcdef1234567890abcdef12",
|
||||
status: "11111111-1111-4111-8111-111111111111",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(conservative.details).toEqual({
|
||||
source_id: "cursor",
|
||||
reason: "abcdef1234567890abcdef1234567890abcdef12",
|
||||
status: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops malformed logger events without interrupting later writes", async () => {
|
||||
const dataDir = await root();
|
||||
const logger = new DiagnosticLogger({
|
||||
dataDir,
|
||||
service: "desktop",
|
||||
sessionId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
|
||||
await expect(
|
||||
logger.log({
|
||||
level: "info",
|
||||
component: "desktop.sdk_migration",
|
||||
event: "sdk_migration.startup",
|
||||
outcome: "succeeded",
|
||||
details: { nested: { secret: "private" } } as never,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
await logger.log({
|
||||
level: "info",
|
||||
component: "desktop.sdk_migration",
|
||||
event: "sdk_migration.startup",
|
||||
outcome: "succeeded",
|
||||
details: { arbitrary_state: "cutover" },
|
||||
});
|
||||
|
||||
const result = await readDiagnosticEvents(dataDir);
|
||||
expect(result.invalid_lines).toBe(0);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0].details).toEqual({ arbitrary_state: "cutover" });
|
||||
});
|
||||
|
||||
it("keeps code locations but strips exception messages and absolute prefixes", () => {
|
||||
@@ -377,162 +215,29 @@ describe("diagnostic log", () => {
|
||||
expect(JSON.stringify(safe)).not.toContain("/Users/me");
|
||||
});
|
||||
|
||||
it("exports events through an independent detail whitelist", async () => {
|
||||
it("marks incidents and exports only validated events", async () => {
|
||||
const dataDir = await root();
|
||||
const logger = new DiagnosticLogger({ dataDir, service: "desktop" });
|
||||
const incident = await logger.markIncident({
|
||||
component: "desktop.diagnostics",
|
||||
status: "degraded",
|
||||
});
|
||||
await logger.log({
|
||||
level: "info",
|
||||
component: "desktop.diagnostics",
|
||||
event: "diagnostics.checked",
|
||||
outcome: "succeeded",
|
||||
details: {
|
||||
count: 1,
|
||||
reason: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signaturevalue",
|
||||
status: "ASIA1234567890ABCDEF",
|
||||
source_id: "GOCSPX-clientSecret_A1",
|
||||
mode: "Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
|
||||
action: "AIzaSyExampleCredential1234567890",
|
||||
platform: "x_ASIA1234567890ABCDEF",
|
||||
phase: "x_GOCSPX-clientSecret_A1",
|
||||
local_only_secret: "must-not-export",
|
||||
},
|
||||
});
|
||||
await logger.log({
|
||||
level: "error",
|
||||
component: "credential.private",
|
||||
event: "token.failed",
|
||||
outcome: "ghp_private_token",
|
||||
trace_id: "ghp_private_token",
|
||||
instance_id: "ASIA1234567890ABCDEF",
|
||||
request_id: "GOCSPX-clientSecret_A1",
|
||||
task_id: "Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
|
||||
error_code: "secret.token",
|
||||
});
|
||||
const target = path.join(await root(), "bundle.json");
|
||||
const result = await exportDiagnosticBundle({
|
||||
dataDir,
|
||||
target,
|
||||
productVersion: "1.2.3",
|
||||
status: { status: "degraded", local_only_secret: "must-not-export" },
|
||||
});
|
||||
expect(result).toEqual({ event_count: 3, invalid_lines: 0 });
|
||||
const payload = JSON.parse(await readFile(target, "utf8"));
|
||||
expect(payload.events[0].incident_id).toBe(incident.incident_id);
|
||||
expect(payload.events[1].details).toEqual({ count: 1 });
|
||||
expect(payload.events[2]).toMatchObject({
|
||||
component: "diagnostic.redacted",
|
||||
event: "diagnostic.redacted",
|
||||
outcome: "redacted",
|
||||
});
|
||||
expect(payload.events[2].trace_id).toBeUndefined();
|
||||
expect(payload.events[2].instance_id).toBeUndefined();
|
||||
expect(payload.events[2].request_id).toBeUndefined();
|
||||
expect(payload.events[2].task_id).toBeUndefined();
|
||||
expect(payload.events[2].error_code).toBeUndefined();
|
||||
expect(JSON.stringify(payload)).not.toContain("local_only_secret");
|
||||
expect(JSON.stringify(payload)).not.toContain("must-not-export");
|
||||
expect(JSON.stringify(payload)).not.toContain("eyJhbGciOiJIUzI1NiJ9");
|
||||
expect(JSON.stringify(payload)).not.toContain("ASIA1234567890ABCDEF");
|
||||
expect(JSON.stringify(payload)).not.toContain("GOCSPX-clientSecret_A1");
|
||||
expect(JSON.stringify(payload)).not.toContain("x_ASIA1234567890ABCDEF");
|
||||
expect(JSON.stringify(payload)).not.toContain("x_GOCSPX-clientSecret_A1");
|
||||
expect(JSON.stringify(payload)).not.toContain("Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8");
|
||||
expect(JSON.stringify(payload)).not.toContain(
|
||||
"AIzaSyExampleCredential1234567890",
|
||||
);
|
||||
expect(JSON.stringify(payload)).not.toContain("ghp_private_token");
|
||||
});
|
||||
|
||||
it("fails closed when exported build identity has a credential shape", async () => {
|
||||
const dataDir = await root();
|
||||
const outputDir = await root();
|
||||
await expect(
|
||||
exportDiagnosticBundle({
|
||||
dataDir,
|
||||
target: path.join(outputDir, "bundle.json"),
|
||||
productVersion: "xoxb-123456-abcdef",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_product_version_invalid");
|
||||
await expect(
|
||||
exportDiagnosticZip({
|
||||
dataDir,
|
||||
target: path.join(outputDir, "commit.zip"),
|
||||
productVersion: "1.2.3",
|
||||
commit: "ghp_private_token",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_commit_invalid");
|
||||
await expect(
|
||||
exportDiagnosticZip({
|
||||
dataDir,
|
||||
target: path.join(outputDir, "sha.zip"),
|
||||
productVersion: "1.2.3",
|
||||
coreSha256: "AKIDExampleSecretId1234567890",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_sha256_invalid");
|
||||
expect(await readdir(outputDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails closed without publishing a JSON bundle when any line is invalid", async () => {
|
||||
const dataDir = await root();
|
||||
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dataDir, "logs", "diagnostic-core.jsonl"),
|
||||
"not-json\n",
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const outputDir = await root();
|
||||
const target = path.join(outputDir, "bundle.json");
|
||||
await expect(
|
||||
exportDiagnosticBundle({
|
||||
dataDir,
|
||||
target,
|
||||
productVersion: "1.2.3",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_export_invalid_lines");
|
||||
expect(await readdir(outputDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails closed when the diagnostic directory cannot be read", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const dataDir = await root();
|
||||
const logs = path.join(dataDir, "logs");
|
||||
await mkdir(logs, { recursive: true, mode: 0o700 });
|
||||
await writeFile(path.join(logs, "diagnostic-core.jsonl"), "{}\n");
|
||||
const outputDir = await root();
|
||||
const target = path.join(outputDir, "bundle.json");
|
||||
await chmod(logs, 0o000);
|
||||
try {
|
||||
await expect(
|
||||
exportDiagnosticBundle({
|
||||
dataDir,
|
||||
target,
|
||||
productVersion: "1.2.3",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EACCES" });
|
||||
} finally {
|
||||
await chmod(logs, 0o700);
|
||||
}
|
||||
expect(await readdir(outputDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("never overwrites an existing export target or leaves temporary files", async () => {
|
||||
const dataDir = await root();
|
||||
const outputDir = await root();
|
||||
const target = path.join(outputDir, "bundle.json");
|
||||
await writeFile(target, "existing-artifact", { mode: 0o600 });
|
||||
await expect(
|
||||
exportDiagnosticBundle({
|
||||
dataDir,
|
||||
target,
|
||||
productVersion: "1.2.3",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EEXIST" });
|
||||
expect(await readFile(target, "utf8")).toBe("existing-artifact");
|
||||
expect(await readdir(outputDir)).toEqual(["bundle.json"]);
|
||||
const target = path.join(await root(), "bundle.json");
|
||||
const result = await exportDiagnosticBundle({
|
||||
dataDir,
|
||||
target,
|
||||
productVersion: "1.2.3",
|
||||
status: { status: "degraded" },
|
||||
});
|
||||
expect(result).toEqual({ event_count: 1, invalid_lines: 1 });
|
||||
const payload = JSON.parse(await readFile(target, "utf8"));
|
||||
expect(payload.events[0].incident_id).toBe(incident.incident_id);
|
||||
expect(JSON.stringify(payload)).not.toContain(dataDir);
|
||||
});
|
||||
|
||||
it("preserves microsecond ordering across service log files", async () => {
|
||||
@@ -575,7 +280,7 @@ describe("diagnostic log", () => {
|
||||
expect(result.events[1].timestamp).toBe("2026-10-14T00:00:00.123999Z");
|
||||
});
|
||||
|
||||
it("exports only reprojected events, a strict summary and README", async () => {
|
||||
it("exports a private seven-day zip with summary, logs, crashes and markers", async () => {
|
||||
const dataDir = await root();
|
||||
const outputDir = await root();
|
||||
const crashDir = await root();
|
||||
@@ -621,178 +326,39 @@ describe("diagnostic log", () => {
|
||||
markers: [
|
||||
{
|
||||
timestamp: "2026-10-05T01:30:00.000Z",
|
||||
description:
|
||||
"蓝莓没出来 /Users/private/query.txt ASIA1234567890ABCDEF GOCSPX-clientSecret_A1 Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8 token=ghp_private_token xoxb-123456-abcdef AKIDExampleSecretId1234567890 AIzaSyExampleCredential1234567890 eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signaturevalue",
|
||||
description: "蓝莓没出来",
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
sources: [{ source_id: "workbuddy", imported_messages: 12 }],
|
||||
summary_secret: conversation,
|
||||
background: {
|
||||
status: "exported",
|
||||
runtime_state: "ready",
|
||||
core_state: "healthy",
|
||||
broker_state: "ready",
|
||||
instance_id: "instance-1",
|
||||
secret: conversation,
|
||||
},
|
||||
memory_tasks: { unknown: 1, last_error_codes: { model_failed: 1 } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ filename: "MemHub.zip", file_count: 3 });
|
||||
expect(result.filename).toBe("MemHub.zip");
|
||||
const files = unzipSync(await readFile(target));
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
"README.txt",
|
||||
"diagnostics/events.jsonl",
|
||||
"summary.json",
|
||||
]);
|
||||
const eventFile = files["diagnostics/events.jsonl"];
|
||||
const summaryFile = files["summary.json"];
|
||||
expect(Object.keys(files)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"README.txt",
|
||||
"summary.json",
|
||||
"logs/diagnostic-core.jsonl",
|
||||
"logs/core.log",
|
||||
"crashes/MemHub-2026-10-05.ips",
|
||||
]),
|
||||
);
|
||||
const summary = files["summary.json"];
|
||||
const readme = files["README.txt"];
|
||||
if (!eventFile || !summaryFile || !readme)
|
||||
throw new Error("missing support bundle metadata");
|
||||
const exportedEvent = JSON.parse(strFromU8(eventFile).trim());
|
||||
expect(exportedEvent.details).toEqual({ count: 1 });
|
||||
const summary = JSON.parse(strFromU8(summaryFile));
|
||||
expect(summary.background).toEqual({
|
||||
status: "exported",
|
||||
runtime_state: "ready",
|
||||
core_state: "healthy",
|
||||
broker_state: "ready",
|
||||
instance_id: "instance-1",
|
||||
});
|
||||
expect(summary.sources).toBeUndefined();
|
||||
expect(summary.summary_secret).toBeUndefined();
|
||||
expect(summary.problem_markers).toEqual([
|
||||
{
|
||||
timestamp: "2026-10-05T01:30:00.000Z",
|
||||
description: "user_marked_incident",
|
||||
},
|
||||
]);
|
||||
expect(strFromU8(readme)).toContain("user_marked_incident");
|
||||
expect(strFromU8(readme)).not.toContain("蓝莓没出来");
|
||||
if (!summary || !readme) throw new Error("missing support bundle metadata");
|
||||
expect(strFromU8(summary)).toContain("model_failed");
|
||||
expect(strFromU8(readme)).toContain("蓝莓没出来");
|
||||
const all = Object.values(files)
|
||||
.map((value) => strFromU8(value))
|
||||
.join("\n");
|
||||
expect(all).not.toContain(secret);
|
||||
expect(all).not.toContain("ghp_private_token");
|
||||
expect(all).not.toContain("xoxb-123456-abcdef");
|
||||
expect(all).not.toContain("AKIDExampleSecretId1234567890");
|
||||
expect(all).not.toContain("ASIA1234567890ABCDEF");
|
||||
expect(all).not.toContain("GOCSPX-clientSecret_A1");
|
||||
expect(all).not.toContain("Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8");
|
||||
expect(all).not.toContain("AIzaSyExampleCredential1234567890");
|
||||
expect(all).not.toContain("eyJhbGciOiJIUzI1NiJ9");
|
||||
expect(all).not.toContain(conversation);
|
||||
expect(all).not.toContain(home);
|
||||
expect(all).not.toContain("/Users/");
|
||||
});
|
||||
|
||||
it("fails closed without publishing a ZIP when a diagnostic line is invalid", async () => {
|
||||
const dataDir = await root();
|
||||
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dataDir, "logs", "diagnostic-core.jsonl"),
|
||||
"not-json\n",
|
||||
);
|
||||
const outputDir = await root();
|
||||
const target = path.join(outputDir, "MemHub.zip");
|
||||
await expect(
|
||||
exportDiagnosticZip({
|
||||
dataDir,
|
||||
target,
|
||||
productVersion: "3800",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_export_invalid_lines");
|
||||
expect(await readdir(outputDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("stores and exports only the fixed marker description", async () => {
|
||||
const dataDir = await root();
|
||||
const markerFile = path.join(dataDir, "logs", "problem-markers.jsonl");
|
||||
await recordDiagnosticMarker(
|
||||
dataDir,
|
||||
{
|
||||
timestamp: "2026-10-05T01:00:00.000Z",
|
||||
description:
|
||||
"蓝莓 /Users/private/query.txt ASIA1234567890ABCDEF GOCSPX-clientSecret_A1 Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
|
||||
},
|
||||
"/Users/private",
|
||||
);
|
||||
expect(await readFile(markerFile, "utf8")).toBe(
|
||||
`${JSON.stringify({ timestamp: "2026-10-05T01:00:00.000Z", description: "user_marked_incident" })}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
markerFile,
|
||||
`${await readFile(markerFile, "utf8")}${JSON.stringify({ timestamp: "2026-10-05T01:30:00.000Z", description: "legacy arbitrary text" })}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const target = path.join(await root(), "markers.zip");
|
||||
await exportDiagnosticZip({
|
||||
dataDir,
|
||||
target,
|
||||
productVersion: "3800",
|
||||
now: new Date("2026-10-05T02:00:00.000Z"),
|
||||
});
|
||||
const files = unzipSync(await readFile(target));
|
||||
const summaryFile = files["summary.json"];
|
||||
if (!summaryFile) throw new Error("missing summary.json");
|
||||
const payload = JSON.stringify(JSON.parse(strFromU8(summaryFile)));
|
||||
expect(payload.match(/user_marked_incident/g)).toHaveLength(2);
|
||||
expect(payload).not.toContain("legacy arbitrary text");
|
||||
expect(payload).not.toContain("蓝莓");
|
||||
expect(payload).not.toContain("/Users/");
|
||||
});
|
||||
|
||||
it("never follows a problem marker symlink for reads or writes", async () => {
|
||||
const dataDir = await root();
|
||||
const logs = path.join(dataDir, "logs");
|
||||
await mkdir(logs, { recursive: true });
|
||||
const outside = path.join(await root(), "outside-marker");
|
||||
await writeFile(outside, "private");
|
||||
await symlink(outside, path.join(logs, "problem-markers.jsonl"));
|
||||
|
||||
await expect(
|
||||
recordDiagnosticMarker(dataDir, {
|
||||
timestamp: "2026-10-05T01:00:00.000Z",
|
||||
description: "anything",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_marker_file_unsafe");
|
||||
await expect(
|
||||
exportDiagnosticZip({
|
||||
dataDir,
|
||||
target: path.join(await root(), "markers.zip"),
|
||||
productVersion: "3800",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_marker_file_unsafe");
|
||||
expect(await readFile(outside, "utf8")).toBe("private");
|
||||
});
|
||||
|
||||
it("rejects invalid explicit and persisted markers without publishing a ZIP", async () => {
|
||||
const dataDir = await root();
|
||||
const logs = path.join(dataDir, "logs");
|
||||
await mkdir(logs, { recursive: true });
|
||||
const outputDir = await root();
|
||||
const explicitTarget = path.join(outputDir, "explicit.zip");
|
||||
await expect(
|
||||
exportDiagnosticZip({
|
||||
dataDir,
|
||||
target: explicitTarget,
|
||||
productVersion: "3800",
|
||||
markers: [{ timestamp: "invalid", description: "marker" }],
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_marker_invalid");
|
||||
await writeFile(path.join(logs, "problem-markers.jsonl"), "not-json\n");
|
||||
const persistedTarget = path.join(outputDir, "persisted.zip");
|
||||
await expect(
|
||||
exportDiagnosticZip({
|
||||
dataDir,
|
||||
target: persistedTarget,
|
||||
productVersion: "3800",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_marker_invalid");
|
||||
expect(await readdir(outputDir)).toEqual([]);
|
||||
expect(all).toContain("~/MemHub");
|
||||
});
|
||||
|
||||
it("an incident retrieves its surrounding session rather than only its marker", async () => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -59,7 +59,6 @@ export {
|
||||
diagnosticZipFilename,
|
||||
exportDiagnosticBundle,
|
||||
exportDiagnosticZip,
|
||||
projectDiagnosticEventForSafeOutput,
|
||||
pruneDiagnosticLogs,
|
||||
readDiagnosticEvents,
|
||||
recordDiagnosticMarker,
|
||||
|
||||
@@ -36,7 +36,7 @@ MEMMY_LICENSE = (
|
||||
MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md")
|
||||
MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887"
|
||||
HY_MEMORY_NAME = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.5"
|
||||
HY_MEMORY_VERSION = "2.0.5.dev3"
|
||||
|
||||
|
||||
class SbomError(RuntimeError):
|
||||
|
||||
@@ -12,8 +12,8 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(readme, /`hy-memory==2\.0\.5`/);
|
||||
assert.match(readme, /`v1\.0\.5` 的发行目标是 Linux x64\/arm64 Runtime/);
|
||||
assert.match(readme, /`hy-memory==2\.0\.5\.dev3`/);
|
||||
assert.match(readme, /`v1\.0\.4` 的发行目标是 Linux x64\/arm64 Runtime/);
|
||||
assert.match(readme, /Linux 制品由 GitHub Actions/);
|
||||
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
|
||||
assert.match(readme, /release-manifest\.json/);
|
||||
|
||||
@@ -13,7 +13,7 @@ spec.loader.exec_module(module)
|
||||
|
||||
|
||||
def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None:
|
||||
dist_info = tmp_path / "hy_memory-2.0.5.dist-info"
|
||||
dist_info = tmp_path / "hy_memory-2.0.5.dev3.dist-info"
|
||||
licenses = dist_info / "licenses"
|
||||
licenses.mkdir(parents=True)
|
||||
(dist_info / "METADATA").write_text(
|
||||
@@ -21,7 +21,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
|
||||
(
|
||||
"Metadata-Version: 2.4",
|
||||
"Name: hy-memory",
|
||||
"Version: 2.0.5",
|
||||
"Version: 2.0.5.dev3",
|
||||
"License-Expression: Apache-2.0",
|
||||
"",
|
||||
)
|
||||
@@ -38,7 +38,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
|
||||
notice = module.hy_memory_notice(dist_info)
|
||||
|
||||
assert notice["name"] == "hy-memory"
|
||||
assert notice["version"] == "2.0.5"
|
||||
assert notice["version"] == "2.0.5.dev3"
|
||||
assert notice["license"] == "Apache-2.0"
|
||||
assert notice["copyright"] == "Copyright (C) 2026 Tencent. All rights reserved."
|
||||
assert "Licensed under the Apache License" in notice["text"]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hy-memory"
|
||||
version = "2.0.5"
|
||||
version = "2.0.5.dev3"
|
||||
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
Reference in new issue
Block a user