Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3809a34b | ||
|
|
efe7df4048 | ||
|
|
072e0e5e90 | ||
|
|
a0221bc434 | ||
|
|
754555e3c3 | ||
|
|
b43c967baa |
No files matched your search
@@ -172,6 +172,20 @@ result = client.add_extracted(
|
||||
该入口仍走 SDK reconcile/持久化;MemHub 只做管理面门禁与可选
|
||||
`memory_id` 记录。
|
||||
|
||||
应用的「添加记忆」、MCP `MemHub_write`(经 broker `POST /memories` 以本机可信身份转发)
|
||||
共用同一个本机 HTTP 合同,直接新增一条记忆:
|
||||
|
||||
```text
|
||||
POST /api/v2/memories {request_id, text, scope: "all-apps"}
|
||||
GET /api/v2/memories/additions/{request_id} # 该次新增的状态
|
||||
```
|
||||
|
||||
两者返回同一回执(`evidence_id`、`text`、`scope`、`created_at`、`state`、`task_id`、
|
||||
`memory_ids`、`error`)。同一 `request_id` 重放返回原回执;内容不同返回 409
|
||||
`memory_add_conflict`,未知 `request_id` 返回 404 `memory_add_not_found`。新增走持久
|
||||
写入队列的优先通道,最终调用 `add_extracted` + reconcile。带外部读取信封的请求
|
||||
一律拒绝(403 `external_endpoint_denied`)。
|
||||
|
||||
## Search:搜索记忆
|
||||
|
||||
```python
|
||||
|
||||
+3
-2
@@ -109,8 +109,9 @@ Node、Host Bridge 或模型。用户显式执行 `memhub install` 后,CLI 才
|
||||
Python Core 和运行所需的 Python 依赖位于 MemHub 受管 Runtime,不写入系统 Python。
|
||||
MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime 版本目录中。
|
||||
`npm uninstall` 只移除 npm CLI;`memhub uninstall` 移除服务、受管 Runtime 和 CLI 链接,
|
||||
默认保留 Memory。只有独立的 `memhub delete-data` 在 Runtime 停止且二次确认精确路径后
|
||||
才会删除数据。
|
||||
默认保留 Memory。只有独立的 `memhub delete-data` 在 Runtime 停止、显式加 `--yes` 并用
|
||||
`--confirm <数据目录>` 再写一遍同一个目录后才会删除数据(不加参数只预览要删除的目录)。`memhub stop` 停止受管服务时也会关闭
|
||||
登录自动启动,再次 `memhub start` 会恢复。
|
||||
|
||||
当前平台能力和安装边界以源码、CI 与发布说明为准。未完成真机验收的平台不会仅凭跨平台
|
||||
测试宣称可用。
|
||||
|
||||
@@ -30,9 +30,7 @@ const REQUIRED_COMMAND_SURFACE = [
|
||||
"sync",
|
||||
"memories",
|
||||
"profile",
|
||||
"calls",
|
||||
"notes",
|
||||
"graph",
|
||||
"graph-width",
|
||||
"models",
|
||||
"host",
|
||||
"doctor",
|
||||
@@ -50,19 +48,17 @@ const REQUIRED_COMMAND_SURFACE = [
|
||||
"uninstall",
|
||||
"delete-data",
|
||||
"recall",
|
||||
"setup",
|
||||
"scan",
|
||||
"progress",
|
||||
"jobs",
|
||||
"path",
|
||||
"scan-roots",
|
||||
"adapter",
|
||||
"write",
|
||||
"task",
|
||||
"serve",
|
||||
];
|
||||
|
||||
const REQUIRED_MARKERS = [
|
||||
"backup_restore_confirmation_required",
|
||||
"backup_restore_confirmation_mismatch",
|
||||
"backup_restore_runtime_state_unknown",
|
||||
"backup_restore_maintenance_identity_invalid",
|
||||
"runtime_service_activation_unverified",
|
||||
@@ -79,16 +75,18 @@ const REQUIRED_MARKERS = [
|
||||
* exact literal the catalog usage string contains.
|
||||
*/
|
||||
const REQUIRED_SUBCOMMANDS = [
|
||||
{ command: "calls", marker: "memhub calls clear" },
|
||||
{ command: "notes", marker: "memhub notes add" },
|
||||
{ command: "notes", marker: "memhub notes process" },
|
||||
{ command: "memories", marker: "memhub memories add" },
|
||||
{ command: "memories", marker: "memhub memories add-status" },
|
||||
{ command: "memories", marker: "memhub memories update" },
|
||||
{ command: "memories", marker: "memhub memories delete" },
|
||||
{ command: "write", marker: "memhub write submit" },
|
||||
{ command: "write", marker: "memhub write status" },
|
||||
{ command: "graph", marker: "memhub graph width" },
|
||||
{ command: "graph-width", marker: "memhub graph-width --decision" },
|
||||
{ command: "models", marker: "memhub models add" },
|
||||
{ command: "models", marker: "memhub models test" },
|
||||
{ command: "models", marker: "memhub models edit" },
|
||||
{ command: "models", marker: "memhub models activate" },
|
||||
{ command: "sources", marker: "memhub sources connect" },
|
||||
{ command: "jobs", marker: "memhub jobs start" },
|
||||
{ command: "jobs", marker: "memhub jobs <pause|resume|cancel>" },
|
||||
];
|
||||
@@ -100,57 +98,30 @@ const REQUIRED_SUBCOMMANDS = [
|
||||
* counterexamples on the packaged Runtime") had no machine-checkable probe.
|
||||
* These run without `--lifecycle`, so they are safe against any package.
|
||||
*/
|
||||
const PROFILE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const STOPPED_DESTRUCTIVE_PROBES = [
|
||||
{ name: "memories_add", args: ["memories", "add", "synthetic memory"] },
|
||||
{
|
||||
name: "calls_clear_preview",
|
||||
args: ["calls", "clear", "--event", "11111111-1111-4111-8111-111111111111"],
|
||||
},
|
||||
{
|
||||
name: "calls_clear_apply",
|
||||
args: [
|
||||
"calls",
|
||||
"clear",
|
||||
"--event",
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "calls_clear_all_apply",
|
||||
args: ["calls", "clear", "--all", "--confirm"],
|
||||
},
|
||||
{
|
||||
name: "notes_process_apply",
|
||||
args: [
|
||||
"notes",
|
||||
"process",
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
"--confirm",
|
||||
],
|
||||
name: "memories_delete_apply",
|
||||
args: ["memories", "delete", "m-1", "--yes"],
|
||||
},
|
||||
{
|
||||
name: "graph_width_apply",
|
||||
args: ["graph", "width", "--decision", "keep_existing", "--confirm"],
|
||||
args: ["graph-width", "--decision", "keep_existing"],
|
||||
},
|
||||
{
|
||||
name: "models_activate_apply",
|
||||
args: [
|
||||
"models",
|
||||
"activate",
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
"--confirm",
|
||||
],
|
||||
name: "graph_width_rebuild_apply",
|
||||
args: ["graph-width", "--decision", "rebuild_with_new", "--yes"],
|
||||
},
|
||||
{ name: "models_activate_apply", args: ["models", "activate", PROFILE_ID] },
|
||||
{ name: "models_test", args: ["models", "test", PROFILE_ID] },
|
||||
{
|
||||
name: "models_remove_apply",
|
||||
args: ["models", "remove", PROFILE_ID, "--yes"],
|
||||
},
|
||||
{
|
||||
name: "models_edit_apply",
|
||||
args: [
|
||||
"models",
|
||||
"edit",
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
"--config-file",
|
||||
"/nonexistent/patch.json",
|
||||
"--confirm",
|
||||
],
|
||||
name: "sources_connect_import",
|
||||
args: ["sources", "connect", "codex", "--import"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
// Exactly one request for two sources — the batch endpoint, per contract.
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
@@ -165,6 +166,7 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(document.state).toBe("partial");
|
||||
const byId = Object.fromEntries(
|
||||
@@ -206,6 +208,7 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
[],
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(document.sources.every((row) => row.status === "failed")).toBe(true);
|
||||
expect(
|
||||
@@ -249,6 +252,7 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
[],
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
const busy = document.sources.find((row) => row.source_id === "cursor");
|
||||
expect(document.state).toBe("partial");
|
||||
@@ -288,6 +292,7 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
const notFoundRow = document.sources.find(
|
||||
(row) => row.source_id === "cursor",
|
||||
@@ -296,3 +301,82 @@ describe("A19 batch execution", () => {
|
||||
expect(document.not_found_source_ids).toContain("cursor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment-override sources need --confirm-overrides", () => {
|
||||
it("leaves them out and names them unless confirmed", async () => {
|
||||
const planned = await planBatchImport(
|
||||
readerFor([
|
||||
found("codex"),
|
||||
found("dsh", {
|
||||
candidate_kind: "environment_override",
|
||||
requires_override_confirmation: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const post = vi.fn(
|
||||
async (_path: string, body: Record<string, unknown>) => ({
|
||||
state: "committed" as const,
|
||||
data: {
|
||||
request_id: body.request_id,
|
||||
accepted_source_ids: body.source_ids,
|
||||
failed_sources: {},
|
||||
jobs: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const operations = { post } as unknown as CoreOperations;
|
||||
const skipped = await executeBatchImport(
|
||||
planned.candidates,
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(post.mock.calls[0]![1]).toMatchObject({
|
||||
source_ids: ["codex"],
|
||||
confirm_environment_overrides: false,
|
||||
});
|
||||
expect(skipped.override_required_source_ids).toEqual(["dsh"]);
|
||||
expect(renderBatchScanText(skipped)).toContain("--confirm-overrides");
|
||||
|
||||
const confirmed = await executeBatchImport(
|
||||
planned.candidates,
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: true },
|
||||
);
|
||||
expect(post.mock.calls[1]![1]).toMatchObject({
|
||||
source_ids: ["codex", "dsh"],
|
||||
confirm_environment_overrides: true,
|
||||
});
|
||||
expect(confirmed.override_required_source_ids).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses without a request when every detected source needs confirmation", async () => {
|
||||
const planned = await planBatchImport(
|
||||
readerFor([
|
||||
found("dsh", {
|
||||
candidate_kind: "environment_override",
|
||||
requires_override_confirmation: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const post = vi.fn();
|
||||
const document = await executeBatchImport(
|
||||
planned.candidates,
|
||||
readerFor([]),
|
||||
{ post } as unknown as CoreOperations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(document).toMatchObject({
|
||||
state: "rejected",
|
||||
code: "environment_override_confirmation_required",
|
||||
});
|
||||
expect(batchScanExitCode(document)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
/** A19: one-shot scan/import of every *detected* source.
|
||||
/** A19: one-shot connect/import of every *detected* source
|
||||
* (`sources connect --all-detected --import`).
|
||||
*
|
||||
* The engine already takes many sources in a single `POST /api/v1/sources/connect`
|
||||
* (`source_ids` is a 1..32 tuple) and answers per source. The CLI used to reject
|
||||
@@ -37,13 +38,16 @@ export interface BatchSourceRow {
|
||||
export interface BatchScanDocument {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "scan" | "init";
|
||||
command: "sources connect";
|
||||
state: "committed" | "partial" | "rejected" | "unknown" | "no_sources";
|
||||
request_id: string | null;
|
||||
accepted_source_ids: string[];
|
||||
sources: BatchSourceRow[];
|
||||
/** `location_state != found` rows are listed so the user can see what was skipped. */
|
||||
not_found_source_ids: string[];
|
||||
/** Detected sources whose location comes from an environment override and
|
||||
* that were left out because `--confirm-overrides` was not given. */
|
||||
override_required_source_ids?: string[];
|
||||
code?: string;
|
||||
}
|
||||
|
||||
@@ -118,12 +122,39 @@ export async function planBatchImport(reader: CoreReader): Promise<
|
||||
|
||||
/** Run the batch connect once and fold the per-source receipt into rows. */
|
||||
export async function executeBatchImport(
|
||||
candidates: readonly SourceConnectionCandidate[],
|
||||
detected: readonly SourceConnectionCandidate[],
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
notFound: readonly string[],
|
||||
options: { confirmOverrides: boolean },
|
||||
): Promise<BatchScanDocument> {
|
||||
const requestId = randomUUID();
|
||||
// An environment-override location is a choice the user has to confirm:
|
||||
// without `--confirm-overrides` those sources are left out and named.
|
||||
const skipped = options.confirmOverrides
|
||||
? []
|
||||
: detected
|
||||
.filter((candidate) => candidate.requires_override_confirmation)
|
||||
.map((candidate) => candidate.source_id);
|
||||
const overrideFields =
|
||||
skipped.length > 0 ? { override_required_source_ids: skipped } : {};
|
||||
const candidates = detected.filter(
|
||||
(candidate) => !skipped.includes(candidate.source_id),
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "sources connect",
|
||||
state: "rejected",
|
||||
request_id: null,
|
||||
accepted_source_ids: [],
|
||||
sources: [],
|
||||
not_found_source_ids: [...notFound],
|
||||
...overrideFields,
|
||||
code: "environment_override_confirmation_required",
|
||||
};
|
||||
}
|
||||
// Same shared builder as the single-source import and the Web console, so a
|
||||
// batch connect cannot drift from either.
|
||||
const request = buildSourceImportRequest({
|
||||
@@ -141,9 +172,9 @@ export async function executeBatchImport(
|
||||
: [[candidate.source_id, candidate.candidate_revision]],
|
||||
),
|
||||
),
|
||||
confirmEnvironmentOverrides: candidates.some(
|
||||
(candidate) => candidate.requires_override_confirmation,
|
||||
),
|
||||
confirmEnvironmentOverrides:
|
||||
options.confirmOverrides &&
|
||||
candidates.some((candidate) => candidate.requires_override_confirmation),
|
||||
fullRescan: false,
|
||||
onboarding: true,
|
||||
enableSync: true,
|
||||
@@ -154,7 +185,7 @@ export async function executeBatchImport(
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "scan",
|
||||
command: "sources connect",
|
||||
state: result.state,
|
||||
request_id: requestId,
|
||||
accepted_source_ids: [],
|
||||
@@ -168,6 +199,7 @@ export async function executeBatchImport(
|
||||
failure_reason: result.code,
|
||||
})),
|
||||
not_found_source_ids: [...notFound],
|
||||
...overrideFields,
|
||||
code: result.code,
|
||||
};
|
||||
}
|
||||
@@ -254,7 +286,7 @@ export async function executeBatchImport(
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "scan",
|
||||
command: "sources connect",
|
||||
state:
|
||||
failedCount === 0
|
||||
? "committed"
|
||||
@@ -270,6 +302,7 @@ export async function executeBatchImport(
|
||||
accepted_source_ids: accepted,
|
||||
sources: rows,
|
||||
not_found_source_ids: [...notFound],
|
||||
...overrideFields,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,5 +334,10 @@ export function renderBatchScanText(document: BatchScanDocument): string {
|
||||
: document.state === "partial"
|
||||
? `部分完成:${document.accepted_source_ids.length} 个成功,其余见上方`
|
||||
: `未完成:${document.code ?? "unknown"}`;
|
||||
return `${summary}\n${lines.join("\n")}\n`;
|
||||
const overrides = document.override_required_source_ids ?? [];
|
||||
const hint =
|
||||
overrides.length > 0
|
||||
? `\n未接入(目录来自环境变量覆盖,需确认):${overrides.join("、")}\n确认目录无误后加 --confirm-overrides 重新运行。`
|
||||
: "";
|
||||
return `${summary}\n${lines.join("\n")}${hint}\n`;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// Contract fixes from the CLI review: exit codes, where JSON goes, the global
|
||||
// `--data-dir`, and the write idempotency key.
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
import { COMMANDS } from "./command-catalog.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
request: vi.fn(),
|
||||
}));
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
create: vi.fn(() => transport),
|
||||
get: transport.get,
|
||||
post: transport.post,
|
||||
request: transport.request,
|
||||
isAxiosError: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
inspectBackgroundHealth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
const ATTACHED = {
|
||||
instanceId: "instance-1",
|
||||
apiToken: "synthetic-runtime-token",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
servicePid: 4242,
|
||||
health: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.2.3",
|
||||
instance_id: "instance-1",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const method of Object.values(transport)) method.mockReset();
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
instanceId: "instance-1",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
version: "1.2.3",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue(ATTACHED);
|
||||
runtime.inspectBackgroundHealth.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("--data-dir is a global option", () => {
|
||||
it("points a Runtime-attached command at that data directory", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { request_id: "r", profile: null, elapsed_ms: 1 },
|
||||
});
|
||||
const result = await capturedRun(
|
||||
["profile", "--data-dir", "/synthetic/other", "--json"],
|
||||
{ ONE_MEMORY_DATA_DIR: "/synthetic/default" },
|
||||
);
|
||||
expect(result.code).toBe(0);
|
||||
expect(runtime.inspectRuntimeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dataDir: "/synthetic/other" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("is accepted by every command the catalog used to advertise it on", async () => {
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "stopped",
|
||||
code: "runtime_not_running",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue(null);
|
||||
for (const argv of [
|
||||
["profile"],
|
||||
["scan-roots", "list"],
|
||||
["adapter", "list"],
|
||||
["scan", "--source", "codex"],
|
||||
["write", "status", "r-1"],
|
||||
["backup", "create", "--target", "/synthetic/data/backups/a"],
|
||||
["memories", "list"],
|
||||
]) {
|
||||
const result = await capturedRun([
|
||||
...argv,
|
||||
"--data-dir",
|
||||
"/synthetic/other",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code, argv.join(" ")).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a relative or repeated --data-dir", async () => {
|
||||
for (const argv of [
|
||||
["profile", "--data-dir", "relative"],
|
||||
["profile", "--data-dir", "/a", "--data-dir", "/b"],
|
||||
["profile", "--data-dir"],
|
||||
]) {
|
||||
expect((await capturedRun(argv)).code).toBe(2);
|
||||
}
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Core failures are exit 1, not usage errors", () => {
|
||||
it("adapter probe reports a Core refusal with its code on stdout", async () => {
|
||||
transport.request.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "source_not_authorized" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"adapter",
|
||||
"probe",
|
||||
"--source",
|
||||
"codex",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "adapter probe",
|
||||
state: "failed",
|
||||
code: "source_not_authorized",
|
||||
exit_code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("adapter probe prints readable text without --json", async () => {
|
||||
transport.request.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { source: "codex", available: true },
|
||||
});
|
||||
const result = await capturedRun(["adapter", "probe", "--source", "codex"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("来源 codex 的探测结果");
|
||||
expect(result.stdout).toContain("available: true");
|
||||
});
|
||||
|
||||
it("scan-roots add posts directly and reports a refusal as exit 1", async () => {
|
||||
transport.request.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "scan_root_conflict" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"scan-roots",
|
||||
"add",
|
||||
"--root",
|
||||
"/synthetic/root",
|
||||
"--source",
|
||||
"codex",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(transport.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "post",
|
||||
url: "http://127.0.0.1:8765/api/v1/scan-roots",
|
||||
data: { root: "/synthetic/root", source: "codex", recursive: true },
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(result.stdout).code).toBe("scan_root_conflict");
|
||||
});
|
||||
|
||||
it("the old `path add` spelling still works", async () => {
|
||||
transport.request.mockResolvedValue({ status: 200, data: { ok: true } });
|
||||
const result = await capturedRun([
|
||||
"path",
|
||||
"remove",
|
||||
"--root",
|
||||
"/synthetic/root",
|
||||
"--yes",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.request.mock.calls[0][0]).toMatchObject({
|
||||
method: "delete",
|
||||
data: { root: "/synthetic/root" },
|
||||
});
|
||||
});
|
||||
|
||||
it("scan --source runs directly", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { job_id: "j1", source_id: "codex", status: "pending" },
|
||||
});
|
||||
const result = await capturedRun(["scan", "--source", "codex", "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "scan",
|
||||
state: "committed",
|
||||
scan: { job_id: "j1" },
|
||||
});
|
||||
// A scan without a source is a usage error, not a preview.
|
||||
expect((await capturedRun(["scan"])).code).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("write submit keeps its idempotency key", () => {
|
||||
async function messagesFile(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "memhub-write-"));
|
||||
const file = path.join(directory, "messages.json");
|
||||
await writeFile(file, JSON.stringify([{ role: "user", content: "hi" }]));
|
||||
return file;
|
||||
}
|
||||
|
||||
it("generates a request id, sends it and prints it with a retry hint", async () => {
|
||||
transport.post.mockImplementation(
|
||||
async (_url: string, body: { request_id: string }) => ({
|
||||
status: 200,
|
||||
data: {
|
||||
request_id: body.request_id,
|
||||
state: "accepted",
|
||||
created_at: "T",
|
||||
updated_at: "T",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await capturedRun([
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s1",
|
||||
"--messages-file",
|
||||
await messagesFile(),
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
const sent = transport.post.mock.calls[0][1].request_id as string;
|
||||
expect(sent).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(result.stdout).toContain(`request_id: ${sent}`);
|
||||
expect(result.stdout).toContain(`--request-id ${sent}`);
|
||||
});
|
||||
|
||||
it("reports a 409 conflict as JSON on stdout with the request id", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "memory_write_conflict" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s1",
|
||||
"--messages-file",
|
||||
await messagesFile(),
|
||||
"--request-id",
|
||||
"req-1",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "write submit",
|
||||
state: "rejected",
|
||||
request_id: "req-1",
|
||||
code: "memory_write_conflict",
|
||||
});
|
||||
});
|
||||
|
||||
it("write status of an unknown id is exit 1 with state not_found", async () => {
|
||||
transport.get.mockResolvedValue({ status: 404, data: {} });
|
||||
const error = Object.assign(new Error("404"), {
|
||||
response: { status: 404 },
|
||||
});
|
||||
transport.get.mockRejectedValue(error);
|
||||
const result = await capturedRun(["task", "--id", "nope", "--json"]);
|
||||
// `axios.isAxiosError` is mocked false, so the read error surfaces as a
|
||||
// failed read; either way it is exit 1, never a silent exit 0.
|
||||
expect(result.code).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor and ui use exit 3 for a stopped Runtime", () => {
|
||||
it("doctor exits 3 when the Runtime is stopped", async () => {
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "stopped",
|
||||
code: "runtime_not_running",
|
||||
});
|
||||
const result = await capturedRun(["doctor", "--json"]);
|
||||
expect(result.code).toBe(3);
|
||||
expect(JSON.parse(result.stdout).schema_version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog exit codes", () => {
|
||||
it("lists exit 2 for every command, since every parser can reject", () => {
|
||||
for (const command of COMMANDS) {
|
||||
expect(command.exit_codes, command.name).toContain(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists exit 3 for every command that attaches to the Runtime", () => {
|
||||
for (const name of [
|
||||
"status",
|
||||
"doctor",
|
||||
"init",
|
||||
"sources",
|
||||
"sync",
|
||||
"progress",
|
||||
"jobs",
|
||||
"models",
|
||||
"memories",
|
||||
"profile",
|
||||
"graph-width",
|
||||
"host",
|
||||
"recall",
|
||||
"scan-roots",
|
||||
"adapter",
|
||||
"scan",
|
||||
"write",
|
||||
"backup",
|
||||
"ui",
|
||||
"onboarding",
|
||||
]) {
|
||||
const command = COMMANDS.find((entry) => entry.name === name);
|
||||
expect(command?.exit_codes, name).toContain(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not claim exit 3 or 4 for lifecycle commands that never return them", () => {
|
||||
for (const name of ["start", "stop", "restart"]) {
|
||||
const command = COMMANDS.find((entry) => entry.name === name);
|
||||
expect(command?.exit_codes).toEqual([0, 1, 2]);
|
||||
}
|
||||
});
|
||||
|
||||
it("says stop turns login auto-start off for a managed service", () => {
|
||||
const stop = COMMANDS.find((entry) => entry.name === "stop");
|
||||
expect(stop?.summary).toContain("登录时也不会自动启动");
|
||||
});
|
||||
});
|
||||
|
||||
async function capturedRun(
|
||||
arguments_: string[],
|
||||
environment: NodeJS.ProcessEnv = { ONE_MEMORY_DATA_DIR: "/synthetic/data" },
|
||||
) {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
return { code: await run(arguments_, environment), stdout, stderr };
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
+317
-22
@@ -1,8 +1,16 @@
|
||||
import { mkdtemp, readdir, readFile, realpath, rm } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
claimRuntimeServiceForApp,
|
||||
clearSdkMigrationBlock,
|
||||
readRuntimeServiceOwner,
|
||||
recordSdkMigrationBlock,
|
||||
resolveRuntimeCliConfig,
|
||||
@@ -922,6 +930,38 @@ describe("public One Memory CLI", () => {
|
||||
target_path: "/tmp/install/current/bin/one-memory",
|
||||
};
|
||||
|
||||
it("says the data was restored when a failed migration put it back", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
runtimeMocks.runSdkMigration.mockResolvedValueOnce({
|
||||
state: "deferred",
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
dataRestored: true,
|
||||
} as never);
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
const failure = JSON.parse(streams.stderr());
|
||||
expect(failure).toMatchObject({
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
data_restored: true,
|
||||
log_file: "/tmp/custom data/logs/sdk-migration.log",
|
||||
});
|
||||
expect(failure.message).toContain("记忆数据已恢复到升级前的状态。");
|
||||
expect(failure.message).toContain(
|
||||
"/tmp/custom data/logs/sdk-migration.log",
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates the home with the new Core before restarting the Runtime", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
@@ -1971,16 +2011,11 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
|
||||
["path", "list", "--json"],
|
||||
["adapter", "list", "--json"],
|
||||
["task", "--id", "nope", "--json"],
|
||||
[
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s",
|
||||
"--messages-file",
|
||||
"/tmp/messages.json",
|
||||
"--json",
|
||||
],
|
||||
["backup", "create", "--target", "/tmp/x", "--yes", "--json"],
|
||||
["write", "status", "nope", "--json"],
|
||||
["backup", "create", "--target", "/tmp/x", "--json"],
|
||||
["scan-roots", "add", "--root", "/tmp/x", "--json"],
|
||||
["scan", "--source", "codex", "--json"],
|
||||
["onboarding", "reset", "--json"],
|
||||
])("%s ... --json exits 3 with runtime_not_running", async (...argv) => {
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
@@ -1990,7 +2025,9 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(3);
|
||||
const document = JSON.parse(streams.stderr());
|
||||
// A result, not an error: on stdout like every other --json document.
|
||||
expect(streams.stderr()).toBe("");
|
||||
const document = JSON.parse(streams.stdout());
|
||||
expect(document).toMatchObject({
|
||||
code: "runtime_not_running",
|
||||
exit_code: 3,
|
||||
@@ -1999,6 +2036,48 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
|
||||
expect(document.status).not.toBe("usage_error");
|
||||
});
|
||||
|
||||
it("write submit validates its messages file before attaching, then exits 3", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "memhub-write-"));
|
||||
const file = path.join(directory, "messages.json");
|
||||
await import("node:fs/promises").then(({ writeFile }) =>
|
||||
writeFile(file, JSON.stringify([{ role: "user", content: "hi" }])),
|
||||
);
|
||||
const streams = captureStreams();
|
||||
let missing: number;
|
||||
let code: number;
|
||||
try {
|
||||
missing = await run(
|
||||
[
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s",
|
||||
"--messages-file",
|
||||
path.join(directory, "absent.json"),
|
||||
"--json",
|
||||
],
|
||||
{},
|
||||
);
|
||||
code = await run(
|
||||
[
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s",
|
||||
"--messages-file",
|
||||
file,
|
||||
"--json",
|
||||
],
|
||||
{},
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
expect(missing!).toBe(2);
|
||||
expect(code!).toBe(3);
|
||||
});
|
||||
|
||||
it.each(["pause", "resume"] as const)(
|
||||
"parses sync %s as the direct sync-set command",
|
||||
async (action) => {
|
||||
@@ -2134,6 +2213,94 @@ describe("lifecycle commands report a migration block", () => {
|
||||
expect(output.value()).not.toContain("runtime_not_ready_within_budget");
|
||||
});
|
||||
|
||||
it("restart on a frozen home retries the migration in the foreground first", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "memhub-frozen-"));
|
||||
await recordSdkMigrationBlock(
|
||||
dataDir,
|
||||
"sdk_migration_memory_unreadable",
|
||||
"desktop",
|
||||
{ frozen: { service_was_enabled: true }, data_restored: true },
|
||||
);
|
||||
const controls = {
|
||||
...blockedControls(),
|
||||
migrateSdkData: vi.fn(async () => ({
|
||||
state: "deferred" as const,
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
dataRestored: true,
|
||||
})),
|
||||
};
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["restart", "--json"],
|
||||
{ ONE_MEMORY_DATA_DIR: dataDir },
|
||||
controls,
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(controls.migrateSdkData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dataDir, caller: "cli-restart" }),
|
||||
);
|
||||
// Still frozen: the service is not started to retry on its own.
|
||||
expect(controls.restartManagedRuntime).not.toHaveBeenCalled();
|
||||
expect(controls.ensureRuntime).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
command: "restart",
|
||||
state: "blocked",
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
data_restored: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("restart on a frozen home restarts the Runtime once the retry succeeds", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "memhub-frozen-"));
|
||||
await recordSdkMigrationBlock(
|
||||
dataDir,
|
||||
"sdk_migration_memory_unreadable",
|
||||
"desktop",
|
||||
{ frozen: { service_was_enabled: false } },
|
||||
);
|
||||
const order: string[] = [];
|
||||
const migrateSdkData = vi.fn(async () => {
|
||||
order.push("migrate");
|
||||
await clearSdkMigrationBlock(dataDir);
|
||||
return { state: "cutover" as const };
|
||||
});
|
||||
const output = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["restart", "--json"],
|
||||
{ ONE_MEMORY_DATA_DIR: dataDir },
|
||||
{
|
||||
migrateSdkData,
|
||||
waitForRuntimeReady: vi.fn(async () => true),
|
||||
restartManagedRuntime: vi.fn(async () => {
|
||||
order.push("restart");
|
||||
return "restarted" as const;
|
||||
}),
|
||||
stopRuntime: vi.fn(async () => "stopped" as const),
|
||||
ensureRuntime: vi.fn(async () => {
|
||||
order.push("restart");
|
||||
return { pid: 1 } as never;
|
||||
}),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(migrateSdkData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ caller: "cli-restart" }),
|
||||
);
|
||||
expect(order).toEqual(["migrate", "restart"]);
|
||||
});
|
||||
|
||||
it("restart in text mode names the log file and GitHub issues", async () => {
|
||||
const dataDir = await blockedHome();
|
||||
const output = captureStdout();
|
||||
@@ -2583,7 +2750,8 @@ describe("ui wires the Core session contract without token leakage", () => {
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(4);
|
||||
// A stopped Runtime is exit 3, like every other command.
|
||||
expect(code!).toBe(3);
|
||||
const document = JSON.parse(output.value());
|
||||
expect(document).toMatchObject({
|
||||
schema_version: 1,
|
||||
@@ -2612,6 +2780,120 @@ describe("ui wires the Core session contract without token leakage", () => {
|
||||
// refuse while the Runtime owns the data dir, stop+call Core from a
|
||||
// maintenance process, and never report success from a malformed response.
|
||||
describe("backup restore drives the Core managed-restore contract", () => {
|
||||
it("previews without --yes, exits 0 and starts nothing", async () => {
|
||||
const maintenance = vi.fn();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["backup", "restore", "--source", "/tmp/x", "--json"],
|
||||
{},
|
||||
{ runMaintenanceRestore: maintenance },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(maintenance).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
command: "backup restore",
|
||||
state: "preview",
|
||||
target: "/tmp/x",
|
||||
applied: false,
|
||||
next_step: "memhub backup restore --source /tmp/x --yes --confirm /tmp/x",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes data only when --confirm repeats the data directory", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "memhub-delete-home-"));
|
||||
const dataDir = path.join(home, ".one-memory");
|
||||
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
||||
const deleteData = async (...extra: string[]) => {
|
||||
const output = captureStdout();
|
||||
try {
|
||||
const code = await run(
|
||||
["delete-data", "--data-dir", dataDir, ...extra, "--json"],
|
||||
{ HOME: home },
|
||||
);
|
||||
return { code, document: JSON.parse(output.value()) };
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
};
|
||||
try {
|
||||
const refused = await deleteData("--yes");
|
||||
expect(refused.code).toBe(4);
|
||||
expect(refused.document).toMatchObject({
|
||||
state: "blocked",
|
||||
code: "data_deletion_confirmation_required",
|
||||
});
|
||||
expect(await readdir(dataDir)).toEqual(["logs"]);
|
||||
|
||||
const deleted = await deleteData("--yes", "--confirm", dataDir);
|
||||
expect(deleted.code).toBe(0);
|
||||
expect(await readdir(dataDir).catch(() => [])).toEqual([]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses --yes without repeating the backup path", async () => {
|
||||
const maintenance = vi.fn();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["backup", "restore", "--source", "/tmp/x", "--yes", "--json"],
|
||||
{},
|
||||
{ runMaintenanceRestore: maintenance },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(4);
|
||||
expect(maintenance).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
state: "rejected",
|
||||
code: "backup_restore_confirmation_required",
|
||||
next_step:
|
||||
"恢复需要再写一遍备份路径:memhub backup restore --source /tmp/x --yes --confirm /tmp/x",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores with --yes and the repeated backup path", async () => {
|
||||
const maintenance = vi.fn(async () => ({
|
||||
state: "restored" as const,
|
||||
result: {
|
||||
state: "restored" as const,
|
||||
restoredBytes: 1,
|
||||
previousBackupPath: "/tmp/pre.sqlite3",
|
||||
sourceSchemaVersion: 34,
|
||||
},
|
||||
}));
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
[
|
||||
"backup",
|
||||
"restore",
|
||||
"--source",
|
||||
"/tmp/x",
|
||||
"--yes",
|
||||
"--confirm",
|
||||
"/tmp/x",
|
||||
"--json",
|
||||
],
|
||||
{},
|
||||
{ runMaintenanceRestore: maintenance },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(maintenance).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects while the Runtime is running, with a distinct exit code", async () => {
|
||||
runtimeMocks.inspectRuntimeStatus.mockResolvedValueOnce({
|
||||
state: "running",
|
||||
@@ -2728,7 +3010,7 @@ describe("backup restore drives the Core managed-restore contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires --confirm to repeat the exact source path", async () => {
|
||||
it("refuses a legacy --confirm that does not repeat the source path", async () => {
|
||||
const maintenance = vi.fn();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
@@ -2756,7 +3038,7 @@ describe("backup restore drives the Core managed-restore contract", () => {
|
||||
command: "backup",
|
||||
verb: "restore",
|
||||
state: "rejected",
|
||||
code: "backup_restore_confirmation_required",
|
||||
code: "backup_restore_confirmation_mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2908,7 +3190,7 @@ describe("init onboarding", () => {
|
||||
|
||||
// Boolean flags were idempotent, but accepting them twice hid a duplicated
|
||||
// argv fragment that could also have duplicated a value-bearing flag.
|
||||
it.each(["--import", "--full-rescan", "--confirm-overrides", "--dry-run"])(
|
||||
it.each(["--import", "--confirm-overrides"])(
|
||||
"refuses a repeated init %s flag",
|
||||
async (flag) => {
|
||||
expect(await run(["init", "--source", "codex", flag, flag], {})).toBe(2);
|
||||
@@ -2981,12 +3263,25 @@ describe("init onboarding", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts --dry-run together with --import", async () => {
|
||||
// Parsing must accept the pair; it stops at the Runtime check, which is
|
||||
// not a usage failure.
|
||||
expect(
|
||||
await run(["init", "--source", "codex", "--import", "--dry-run"], {}),
|
||||
).not.toBe(2);
|
||||
// `--dry-run` and `--full-rescan` never did anything (P0-2/P0-3): they are
|
||||
// gone rather than accepted as no-ops.
|
||||
it.each(["--dry-run", "--full-rescan"])(
|
||||
"refuses the removed no-op %s flag",
|
||||
async (flag) => {
|
||||
expect(
|
||||
await run(["init", "--source", "codex", "--import", flag], {}),
|
||||
).toBe(2);
|
||||
},
|
||||
);
|
||||
|
||||
it("still parses the old `init --source --import` spelling", async () => {
|
||||
// It stops at the Runtime check (exit 3), which is not a usage failure.
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["init", "--source", "codex", "--import"], {})).toBe(3);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1664
-2042
File diff suppressed because it is too large.
Load diff
@@ -58,6 +58,12 @@ const CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--yes",
|
||||
summary: "确认执行;不加该参数时只输出计划,不做任何改动。",
|
||||
};
|
||||
/** The one confirmation convention, for irreversible operations only. */
|
||||
const DESTRUCTIVE_CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--yes",
|
||||
summary:
|
||||
"确认这次不可逆操作;不加时只说明将要做什么,不做任何改动,exit 0。(旧的 --confirm 仍作为别名接受。)",
|
||||
};
|
||||
const JSON_OPTION: CommandOption = {
|
||||
flag: "--json",
|
||||
summary: "以 JSON 输出到 stdout,供脚本与 Agent 消费。",
|
||||
@@ -70,21 +76,6 @@ const INTERVAL_OPTION: CommandOption = {
|
||||
flag: "--interval <seconds>",
|
||||
summary: "刷新间隔,默认 5 秒,范围 1..300;必须与 --watch 一起使用。",
|
||||
};
|
||||
const PLAN_FILE_OPTION: CommandOption = {
|
||||
flag: "--plan-file <path>",
|
||||
summary: "读取先前 --json 保存的有界计划文件。",
|
||||
};
|
||||
const EXPLICIT_CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--confirm",
|
||||
summary: "确认执行计划文件逐字描述的操作;缺少时绝不写入。",
|
||||
};
|
||||
// Same flag, but for commands that name their target directly instead of
|
||||
// going through a saved plan file; the plan-file wording would be wrong here.
|
||||
const DIRECT_CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--confirm",
|
||||
summary: "确认执行上面预览的这次操作;缺少时绝不写入。",
|
||||
};
|
||||
|
||||
const SERVICE_OPTIONS: readonly CommandOption[] = [
|
||||
PLATFORM_OPTION,
|
||||
HOME_OPTION,
|
||||
@@ -154,55 +145,77 @@ const INSTALL_OPTIONS: readonly CommandOption[] = [
|
||||
const INSTALL_USAGE =
|
||||
"[--source <path> | --release-manifest-url <https-url>] [--channel <stable|prerelease>] [--install-root <path>] [--home <path>] [--bin-dir <path>] [--data-dir <path>] [--uid <id>] [--xdg-config-home <path>] [--no-shell-path] [--shell-profile <path>] [--preserve-data-dir] [--allow-downgrade] [--yes]";
|
||||
|
||||
const USAGE_EXITS = [EXIT_OK, EXIT_FAILED, EXIT_USAGE] as const;
|
||||
const RUNTIME_EXITS = [
|
||||
EXIT_OK,
|
||||
EXIT_FAILED,
|
||||
EXIT_USAGE,
|
||||
EXIT_RUNTIME_STOPPED,
|
||||
] as const;
|
||||
|
||||
const KIND_OPTION: CommandOption = {
|
||||
flag: "--kind <chat|experience|fact>",
|
||||
summary:
|
||||
"记忆所在的命名空间;工作/编码场景的经验和事实需要同时给 --variant。list 输出里每条都带 kind/variant。",
|
||||
};
|
||||
const VARIANT_OPTION: CommandOption = {
|
||||
flag: "--variant <working|coding>",
|
||||
summary:
|
||||
"经验或事实属于工作还是编码场景;必须和 --kind experience|fact 一起用。",
|
||||
};
|
||||
|
||||
export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "serve",
|
||||
summary: "在前台启动完整 Runtime(Core 与 Host Gateway),不注册系统服务。",
|
||||
usage: "memhub serve",
|
||||
options: [],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED],
|
||||
exit_codes: USAGE_EXITS,
|
||||
},
|
||||
{
|
||||
name: "start",
|
||||
summary: "启动本机 Runtime;已有通过身份校验的实例时只连接,不重复启动。",
|
||||
summary:
|
||||
"启动本机 Runtime;已有通过身份校验的实例时只连接,不重复启动。受管服务会同时恢复登录时自动启动。",
|
||||
usage: "memhub start [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: USAGE_EXITS,
|
||||
},
|
||||
{
|
||||
name: "stop",
|
||||
summary: "停止通过身份校验的本机 Runtime;身份不明确时不发送信号。",
|
||||
summary:
|
||||
"停止通过身份校验的本机 Runtime;身份不明确时不发送信号。受管服务(launchd/systemd、App)停止后登录时也不会自动启动,直到再次运行 memhub start。",
|
||||
usage: "memhub stop [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED],
|
||||
exit_codes: USAGE_EXITS,
|
||||
},
|
||||
{
|
||||
name: "restart",
|
||||
summary: "先停止再启动本机 Runtime,两步串行执行。",
|
||||
usage: "memhub restart [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED],
|
||||
exit_codes: USAGE_EXITS,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
summary: "只读查询 Runtime 运行状态;不启动服务、不迁移数据、不改配置。",
|
||||
usage: "memhub status [--watch] [--interval <seconds>] [--json]",
|
||||
options: [WATCH_OPTION, INTERVAL_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "doctor",
|
||||
summary: "按诊断口径做同样的只读检查;默认不修复任何东西。",
|
||||
summary:
|
||||
"按诊断口径做只读检查并给出修复建议;默认不修复任何东西。Runtime 未运行时 exit 3。",
|
||||
usage: "memhub doctor [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "logs",
|
||||
summary:
|
||||
"读取本机脱敏诊断日志;logs export 会把排查包直接存到下载目录。默认显示最近一次会话的 200 条事件。",
|
||||
"读取本机脱敏诊断日志;logs export 会把排查包(含系统崩溃报告)直接存到下载目录。默认显示最近一次会话的 200 条事件。",
|
||||
usage:
|
||||
"memhub logs export [--json]\nmemhub logs [--since <15m|2h|7d|RFC3339>] [--session <id>] [--trace <id>] [--incident <id>] [--operation <id>] [--request <id>] [--job <id>] [--task <id>] [--component <name>] [--event <name>] [--errors-only] [--tail <1..5000>] [--follow] [--json]",
|
||||
"memhub logs export [--json]\n memhub logs [--since <15m|2h|7d|RFC3339>] [--session <id>] [--trace <id>] [--incident <id>] [--operation <id>] [--request <id>] [--job <id>] [--task <id>] [--component <name>] [--event <name>] [--errors-only] [--tail <1..5000>] [--follow] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--since <time>",
|
||||
@@ -226,64 +239,46 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
"以 NDJSON 输出到 stdout(每行一个脱敏事件;非单 JSON 文档),供脚本与 Agent 消费。",
|
||||
},
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE],
|
||||
exit_codes: USAGE_EXITS,
|
||||
},
|
||||
{
|
||||
name: "init",
|
||||
summary:
|
||||
"首次接入:选择来源 → 选择是否导入历史。接入即开启该 Harness 的处理;后续可用 sync pause/resume 冻结或恢复。在终端且不带 --source 时进入交互引导(默认全选);非终端或加 --json 时不读 stdin、只列出可用来源和下一步。--all-detected 一次接入所有已检测来源。",
|
||||
"首次接入引导。在终端里(未加 --json)进入交互向导:选择来源 → 是否开启处理 → 配置模型。非终端或加 --json 时不读 stdin,只列出可接入的来源和下一步命令。脚本接入请用 sources connect。",
|
||||
usage: "memhub init [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "sources",
|
||||
summary:
|
||||
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。",
|
||||
usage:
|
||||
"memhub init [--json]\n memhub init --source <id> [--source <id> ...] [--import] [--dry-run] [--full-rescan] [--confirm-overrides] [--json]\n memhub init --all-detected [--import] [--dry-run] [--json]",
|
||||
"memhub sources list [--json]\n memhub sources connect <id> [<id> ...] [--import] [--confirm-overrides] [--request-id <uuid>] [--json]\n memhub sources connect --all-detected [--import] [--confirm-overrides] [--json]\n memhub sources receipt <request-id> [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--source <id>",
|
||||
summary: "要接入的来源,可重复;终端下不给则进入交互引导。",
|
||||
flag: "--import",
|
||||
summary:
|
||||
"接入后立即导入历史会话并开启持续同步;不加时只授权,处理保持冻结。",
|
||||
},
|
||||
{
|
||||
flag: "--all-detected",
|
||||
summary:
|
||||
"接入并(加 --import 时)导入全部 location_state=found 的来源。",
|
||||
},
|
||||
{
|
||||
flag: "--import",
|
||||
summary: "接入后立即导入该来源的历史会话(默认只接入不导入)。",
|
||||
},
|
||||
{
|
||||
flag: "--dry-run",
|
||||
summary: "只出计划,不接入也不导入;用于先看会做什么。",
|
||||
},
|
||||
{
|
||||
flag: "--full-rescan",
|
||||
summary: "导入时忽略既有游标做全量重扫。",
|
||||
"接入全部已检测到(location_state=found)的来源;与具体来源互斥。",
|
||||
},
|
||||
{
|
||||
flag: "--confirm-overrides",
|
||||
summary: "确认接受环境覆盖类来源(如 DSH_HOME 指定的目录)。",
|
||||
summary:
|
||||
"确认接受目录来自环境变量覆盖的来源(如 DSH_HOME 指定的目录);不加时这些来源被拒绝(指定来源时)或跳过并列出(--all-detected 时)。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "setup",
|
||||
summary: "生成无头首次导入计划;只读取已有来源,不探测目录、不写授权。",
|
||||
usage: "memhub setup plan --source <id> [--source <id> ...] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--source <id>",
|
||||
summary: "计划导入的来源,可重复 1..32 次。",
|
||||
flag: "--request-id <uuid>",
|
||||
summary:
|
||||
"--import 的幂等键;用同一个 id 重试只会读回第一次的回执,不会重复导入。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "sources",
|
||||
summary: "列出来源、执行已确认的导入计划。",
|
||||
usage:
|
||||
"memhub sources list [--json]\n memhub sources import --plan-file <path> --confirm [--json]\n memhub sources receipt <request-id> [--json]",
|
||||
options: [PLAN_FILE_OPTION, EXPLICIT_CONFIRM_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "sync",
|
||||
@@ -297,7 +292,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "progress",
|
||||
@@ -318,17 +313,17 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
INTERVAL_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "jobs",
|
||||
summary:
|
||||
"启动或控制单个导入任务:start 创建 history/sync 作业;pause/resume/cancel 控制已有作业。所有写操作先出计划,必须显式 --confirm;list/show 为只读。",
|
||||
"查看或控制导入任务:start 创建 history/sync 任务;pause/resume/cancel 控制已有任务。都直接执行。",
|
||||
usage:
|
||||
"memhub jobs list [--json]\n" +
|
||||
" memhub jobs show <job-id> [--json]\n" +
|
||||
" memhub jobs start --source <id> [--operation <history|sync>] [--confirm] [--json]\n" +
|
||||
" memhub jobs <pause|resume|cancel> --job <id> [--confirm] [--json]",
|
||||
" memhub jobs start --source <id> [--operation <history|sync>] [--json]\n" +
|
||||
" memhub jobs <pause|resume|cancel> --job <id> [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--source <id>",
|
||||
@@ -336,123 +331,116 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
{
|
||||
flag: "--operation <history|sync>",
|
||||
summary: "start 的导入类型,默认 history。",
|
||||
summary:
|
||||
"start 的导入类型,默认 history;sync 与 scan --source 相同,立即增量同步一次。",
|
||||
},
|
||||
{
|
||||
flag: "--job <id>",
|
||||
summary: "pause/resume/cancel 的目标导入任务。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "models",
|
||||
summary:
|
||||
"交互配置模型,或生成配置/探测计划、执行已确认操作与查询回执;脚本模式的凭据只从受控文件读取。",
|
||||
"管理生成模型配置。add/test 直接提交并返回操作 ID(异步,用 receipt 查结果);edit/activate 直接执行;remove 不可逆,需 --yes。<profile-id>/<operation-id> 都是 UUID;脚本模式的凭据只从受控文件读取。",
|
||||
usage:
|
||||
"memhub models list [--json]\n memhub models show <profile-id> [--json]\n memhub models plan --config-file <path> [--json]\n memhub models configure --plan-file <path> --confirm [--api-key-file <path>] [--json]\n memhub models receipt <operation-id> [--json]\n memhub models edit <profile-id> --config-file <path> [--confirm] [--api-key-file <path>] [--json]\n memhub models activate <profile-id> [--confirm] [--json]\n memhub models probe-plan <profile-id> [--json]\n memhub models probe --plan-file <path> --confirm [--json]\n memhub models probe-receipt <operation-id> [--json]\n memhub models remove <profile-id> [--confirm] [--json]",
|
||||
"memhub models list [--json]\n memhub models show <profile-id> [--json]\n memhub models add --config-file <path> [--api-key-file <path>] [--activate] [--json]\n memhub models test <profile-id> [--json]\n memhub models receipt <operation-id> [--json]\n memhub models edit <profile-id> --config-file <path> [--api-key-file <path>] [--json]\n memhub models activate <profile-id> [--json]\n memhub models remove <profile-id> [--yes] [--json]",
|
||||
alternative_usage: "memhub models setup",
|
||||
options: [
|
||||
{
|
||||
flag: "--config-file <path>",
|
||||
summary: "不含 api_key 的有界 JSON 模型配置。",
|
||||
summary:
|
||||
"有界 JSON 模型配置,不能含 api_key;edit 的补丁必须带 expected_revision。",
|
||||
},
|
||||
PLAN_FILE_OPTION,
|
||||
EXPLICIT_CONFIRM_OPTION,
|
||||
{
|
||||
flag: "--api-key-file <path>",
|
||||
summary: "从 regular file 读取凭据;内容不进入参数、输出或错误。",
|
||||
summary: "从属主专用的普通文件读取凭据;内容不进入参数、输出或错误。",
|
||||
},
|
||||
{
|
||||
flag: "--activate",
|
||||
summary: "add 成功后把新配置设为当前使用。",
|
||||
},
|
||||
DESTRUCTIVE_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "memories",
|
||||
summary: "通过 SDK 数据面列出、查看、修改或物理删除记忆。",
|
||||
summary:
|
||||
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes。",
|
||||
usage:
|
||||
"memhub memories list [--status <state>] [--offset <n>] [--limit <n>] [--json]\n memhub memories show <memory-id> [--json]\n memhub memories update <memory-id> --content-file <path> [--confirm] [--json]\n memhub memories delete <memory-id> [--confirm] [--json]",
|
||||
"memhub memories list [--scene <all|chat|working|coding>] [--source <id>] [--query <text>] [--status <state>] [--offset <n>] [--limit <n>] [--json]\n memhub memories show <memory-id> [--kind <kind>] [--variant <variant>] [--json]\n memhub memories add <text> [--request-id <uuid>] [--json]\n memhub memories add --content-file <path> [--request-id <uuid>] [--json]\n memhub memories add-status <request-id> [--json]\n memhub memories update <memory-id> --content-file <path> [--kind <kind>] [--variant <variant>] [--task <text>] [--json]\n memhub memories delete <memory-id> [--kind <kind>] [--variant <variant>] [--yes] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--scene <all|chat|working|coding>",
|
||||
summary: "只列一个场景,默认 all。working/coding 列的是该场景的经验。",
|
||||
},
|
||||
{ flag: "--source <id>", summary: "只列某个来源写入的记忆。" },
|
||||
{
|
||||
flag: "--query <text>",
|
||||
summary:
|
||||
"关键词搜索(只搜 active 记忆,在最相关的一批里匹配所有关键词,输出 search_window 时表示可能少计)。",
|
||||
},
|
||||
{
|
||||
flag: "--status <state>",
|
||||
summary: `记忆状态,取值 ${MEMORY_STATUSES.join("、")}。`,
|
||||
summary: `记忆状态,取值 ${MEMORY_STATUSES.join("、")},默认 active。`,
|
||||
},
|
||||
{ flag: "--offset <n>", summary: "跳过前 n 条,默认 0。" },
|
||||
{
|
||||
flag: "--limit <n>",
|
||||
summary: "本页最多返回多少条,默认 100,上限 500。",
|
||||
},
|
||||
KIND_OPTION,
|
||||
VARIANT_OPTION,
|
||||
{
|
||||
flag: "--content-file <path>",
|
||||
summary:
|
||||
"从有界普通文件读取 update 的新正文;预览只显示摘要,--confirm 时才写入。",
|
||||
"从有界普通文件读取正文:add 的新记忆(≤12000 字),或 update 的新内容(经验为 solution)。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
{
|
||||
flag: "--task <text>",
|
||||
summary: "update 经验(--kind experience)时同时改写 task。",
|
||||
},
|
||||
{
|
||||
flag: "--request-id <uuid>",
|
||||
summary:
|
||||
"add 的幂等键;用同一个 id 重试只会读回第一次的结果,不会重复添加。不给时自动生成并输出。",
|
||||
},
|
||||
DESTRUCTIVE_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "profile",
|
||||
summary: "读取 SDK 组装后的用户长期画像(L0 + 已启用的长期画像补充)。",
|
||||
usage: "memhub profile [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "calls",
|
||||
name: "graph-width",
|
||||
summary:
|
||||
"清除本机调用记录;不可逆删除,默认只出计划,必须显式 --confirm 才执行。",
|
||||
"嵌入维度变化、图谱宽度不匹配时的处置决定。keep_existing 与 export_first 直接执行;rebuild_with_new 会归档旧图谱并重建,需 --yes。",
|
||||
usage:
|
||||
"memhub calls clear --event <id> [--event <id> ...] [--confirm] [--json]\n memhub calls clear --all [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--event <id>",
|
||||
summary: "要清除的调用记录事件 ID,可重复,最多 100 条。",
|
||||
},
|
||||
{
|
||||
flag: "--all",
|
||||
summary: "清除全部调用记录;与 --event 互斥。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "notes",
|
||||
summary:
|
||||
"端内新增记忆(memory-notes):从普通文件新增,或显式确认后处理已有笔记。",
|
||||
usage:
|
||||
"memhub notes add --content-file <path> [--json]\n memhub notes apply --plan-file <path> --content-file <path> --confirm [--json]\n memhub notes process <note-id> [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--content-file <path>",
|
||||
summary: "从有界普通文件读取新增正文;计划只保存摘要,执行时重新校验。",
|
||||
},
|
||||
PLAN_FILE_OPTION,
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "graph",
|
||||
summary: "图谱宽度不匹配时的用户决定;默认只出计划,--confirm 后才执行。",
|
||||
usage:
|
||||
"memhub graph width --decision <keep_existing|rebuild_with_new|export_first> [--confirm] [--json]",
|
||||
"memhub graph-width --decision <keep_existing|export_first|rebuild_with_new> [--yes] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--decision <decision>",
|
||||
summary:
|
||||
"keep_existing(保持)、export_first(先导出)、rebuild_with_new(导出后重建)。",
|
||||
"keep_existing(保持现有图谱)、export_first(先导出)、rebuild_with_new(导出后归档旧图谱并按新维度重建)。",
|
||||
},
|
||||
{
|
||||
...DESTRUCTIVE_CONFIRM_OPTION,
|
||||
summary: "rebuild_with_new 必须加;其它决定不需要。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "host",
|
||||
@@ -480,12 +468,12 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "recall",
|
||||
summary:
|
||||
"从当前授权的原文与已沉淀记忆统一检索上下文;本地只读,不调用模型、不触发补索引。",
|
||||
"从原始对话与已沉淀记忆统一检索上下文;本地只读,不调用模型、不触发补索引。文本输出标明每条是「原始对话摘录」还是「记忆」(JSON 里是 kind: raw|memory)。",
|
||||
usage:
|
||||
"memhub recall <query> [--kind <raw|memory>] [--source <id>] [--limit <n>] [--character-budget <n>] [--timeout-ms <n>] [--json]\n memhub recall --query-file <path> [...]\n memhub recall --stdin [...]",
|
||||
options: [
|
||||
@@ -516,21 +504,21 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "service-plan",
|
||||
summary: "生成用户级服务计划;只输出计划,不写文件也不执行命令。",
|
||||
usage: `memhub service-plan ${SERVICE_USAGE}`,
|
||||
options: SERVICE_OPTIONS,
|
||||
exit_codes: [EXIT_OK, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "service-install",
|
||||
summary: "预览用户级服务安装;只有 --yes 才写入服务文件并注册。",
|
||||
usage: `memhub service-install ${SERVICE_USAGE} [--yes]`,
|
||||
options: [...SERVICE_OPTIONS, CONFIRM_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "service-uninstall",
|
||||
@@ -538,15 +526,15 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
"预览用户级服务卸载;只有 --yes 才注销并删除逐字匹配的受管服务文件,不删数据。",
|
||||
usage: `memhub service-uninstall ${SERVICE_USAGE} [--yes]`,
|
||||
options: [...SERVICE_OPTIONS, CONFIRM_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "install",
|
||||
summary:
|
||||
"预览发行目录安装、memhub/memhub-cli/one-memory 短链接、PATH 条目与用户服务计划;只有 --yes 才执行并在失败时回滚。",
|
||||
"预览发行目录安装、memhub/memhub-cli/one-memory 短链接、PATH 条目与用户服务计划;只有 --yes 才执行。记忆数据迁移开始前失败会回滚;迁移开始后失败保留新版本、拒绝启动,并输出迁移日志与反馈地址。",
|
||||
usage: `memhub install ${INSTALL_USAGE}`,
|
||||
options: INSTALL_OPTIONS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "uninstall",
|
||||
@@ -559,24 +547,31 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "upgrade",
|
||||
summary:
|
||||
"预览升级;只有 --yes 才切换受管版本并重启服务,失败时恢复旧版本,不删数据。",
|
||||
"预览升级;只有 --yes 才切换受管版本并重启服务,不删数据。记忆数据迁移开始前失败会恢复旧版本;迁移开始后失败保留新版本、拒绝启动,并输出迁移日志与反馈地址(不会回滚到旧版本)。",
|
||||
usage: `memhub upgrade ${INSTALL_USAGE}`,
|
||||
options: INSTALL_OPTIONS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "delete-data",
|
||||
summary:
|
||||
"独立、二次确认地删除 One Memory Home(数据)。这是唯一会删除数据的命令;uninstall 不会。默认只出预览;需 Runtime 已停止,并显式 --confirm <path> 复述该目录。",
|
||||
"删除 MemHub 数据目录(记忆、来源、配置),不可逆。这是唯一会删除数据的命令;uninstall 不会。需要 Runtime 已停止;不加参数时只预览要删除的目录;删除需要 --yes 并用 --confirm 再写一遍同一个目录。",
|
||||
usage:
|
||||
"memhub delete-data [--data-dir <path>] [--yes --confirm <path>] [--json]",
|
||||
options: [
|
||||
DATA_DIR_OPTION,
|
||||
CONFIRM_OPTION,
|
||||
{
|
||||
flag: "--data-dir <path>",
|
||||
summary: "要删除的数据目录绝对路径;默认当前使用的数据目录。",
|
||||
},
|
||||
{
|
||||
...DESTRUCTIVE_CONFIRM_OPTION,
|
||||
summary:
|
||||
"删除时必须加,且要同时给出 --confirm <path>;不加时只预览,exit 0。",
|
||||
},
|
||||
{
|
||||
flag: "--confirm <path>",
|
||||
summary:
|
||||
"第二次确认:逐字复述预览里的数据目录;缺少它时即使 --yes 也不删除。",
|
||||
"删除时必须加:再写一遍要删除的数据目录,与预览中的目录完全一致才执行。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
@@ -588,113 +583,149 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
"onboarding reset 回到首次运行的引导流程。只清除引导完成状态:不删记忆数据,也不需要卸载重装。",
|
||||
usage: "memhub onboarding reset [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
summary: "显示总帮助或单个命令的用法。",
|
||||
usage: "memhub help [command] [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE],
|
||||
},
|
||||
{
|
||||
name: "version",
|
||||
summary: "显示 CLI 版本。",
|
||||
usage: "memhub version [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE],
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
name: "scan-roots",
|
||||
summary:
|
||||
"查看或登记被扫描的目录(scan roots)。list 只读;add/remove 预览后加 --yes 才提交;均已接入 Runtime 的 scan-roots 端点。",
|
||||
"查看或登记被扫描的目录(scan roots)。add/remove 直接执行。(旧名 path 仍可用。)",
|
||||
usage:
|
||||
"memhub path list [--json]\n" +
|
||||
" memhub path add --root <path> [--source <id>] [--no-recursive] [--yes]\n" +
|
||||
" memhub path remove --root <path> [--yes]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION, CONFIRM_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
"memhub scan-roots list [--json]\n" +
|
||||
" memhub scan-roots add --root <path> [--source <id>] [--no-recursive] [--json]\n" +
|
||||
" memhub scan-roots remove --root <path> [--json]",
|
||||
options: [
|
||||
{ flag: "--root <path>", summary: "要登记或移除的目录。" },
|
||||
{
|
||||
flag: "--source <id>",
|
||||
summary: "把目录绑定到某个来源;不给时由 Runtime 自动识别。",
|
||||
},
|
||||
{ flag: "--no-recursive", summary: "只扫描该目录本身,不进入子目录。" },
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "adapter",
|
||||
summary:
|
||||
"查看 Harness Adapter 的就绪状态与最近一次探测结果(list),或现场探测某个来源(probe --source <id>);两者均为只读,已接入 Runtime 的 adapters/探测端点。",
|
||||
"查看 Harness Adapter 的就绪状态与最近一次探测结果(list),或现场探测某个来源(probe --source <id>);两者均为只读。",
|
||||
usage:
|
||||
"memhub adapter list [--json]\n" +
|
||||
" memhub adapter probe --source <id> [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
|
||||
options: [
|
||||
{ flag: "--source <id>", summary: "probe 的目标来源。" },
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "scan",
|
||||
summary:
|
||||
"立即扫描;默认只输出计划,加 --yes 才真正执行。指定 --source 扫描单个来源,--all 一次扫描/导入全部已检测来源(Runtime 按来源寻址,不接受裸路径)。",
|
||||
usage:
|
||||
"memhub scan [--source <id>] [--full] [--yes] [--json]\n memhub scan --all [--yes] [--json]",
|
||||
"立即增量扫描一个已连接并开启同步的来源,返回导入任务(与 jobs start --operation sync 相同)。接入全部来源请用 sources connect --all-detected --import。",
|
||||
usage: "memhub scan --source <id> [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--source <id>",
|
||||
summary: "按单个来源扫描;与 --all 互斥。",
|
||||
summary: "要扫描的来源。",
|
||||
},
|
||||
{
|
||||
flag: "--all",
|
||||
summary:
|
||||
"处理全部 location_state=found 的来源,按来源逐条回执;部分成功返回非 0。",
|
||||
},
|
||||
{
|
||||
flag: "--full",
|
||||
summary: "忽略既有游标做全量重扫;默认只读取增量。",
|
||||
},
|
||||
DATA_DIR_OPTION,
|
||||
JSON_OPTION,
|
||||
CONFIRM_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "write",
|
||||
summary: "提交 OpenAI messages 到 SDK 写入队列,或按 request id 查询状态。",
|
||||
usage:
|
||||
"memhub write submit --messages-file <path> --session <id> [--source <id>] [--request-id <id>] [--memory-at <RFC3339>] [--live] [--json]\n memhub write status --id <request-id> [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
|
||||
},
|
||||
{
|
||||
name: "task",
|
||||
summary:
|
||||
"兼容别名:按 request id 查询写入状态;新脚本请使用 write status。",
|
||||
usage: "memhub task --id <request-id> [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
|
||||
"提交一段 OpenAI 格式的对话(messages)到写入队列,异步加工成记忆;或按 request id 查询状态。request id 是幂等键:不给时自动生成并输出,重试请带上同一个。新增一条结论式记忆请用 memories add。",
|
||||
usage:
|
||||
"memhub write submit --messages-file <path> --session <id> [--source <id>] [--request-id <id>] [--memory-at <RFC3339>] [--live] [--json]\n memhub write status <request-id> [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--messages-file <path>",
|
||||
summary:
|
||||
"OpenAI messages 数组(或 {messages:[…]}),1..200 条,role 为 user/assistant/tool,至少一条 user。",
|
||||
},
|
||||
{ flag: "--session <id>", summary: "会话 ID。" },
|
||||
{ flag: "--source <id>", summary: "来源应用,默认 memhub-cli。" },
|
||||
{
|
||||
flag: "--request-id <id>",
|
||||
summary:
|
||||
"幂等键:同 id 同内容不会重复入队,同 id 不同内容被拒绝(409)。",
|
||||
},
|
||||
{ flag: "--memory-at <RFC3339>", summary: "对话发生时间,必须带时区。" },
|
||||
{ flag: "--live", summary: "优先处理。" },
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "backup",
|
||||
summary:
|
||||
"备份与恢复 MemHub 数据(两阶段)。create 调用 Runtime 的备份端点;restore 会覆盖现有数据,要求 Runtime 已停止且 `--confirm` 逐字重复 `--source`,随后由非 Runtime 维护态 Core 完成受管校验、保护性备份和原子替换。",
|
||||
"备份与恢复主库(来源、写入队列、配置)。注意:不包含记忆本身——SDK 记忆库 hy-memory/ 不在备份里,restore 也不会替换它。create 的目标要放在 <数据目录>/backups/ 下才能被 restore 使用;restore 覆盖现有主库,需 Runtime 已停止,并加 --yes 与 --confirm <同一个备份路径>。",
|
||||
usage:
|
||||
"memhub backup create --target <path> [--yes] [--json]\n" +
|
||||
" memhub backup restore --source <path> --yes --confirm <path> [--json]",
|
||||
"memhub backup create --target <path> [--json]\n" +
|
||||
" memhub backup restore --source <path> [--yes --confirm <path>] [--json]",
|
||||
options: [
|
||||
DATA_DIR_OPTION,
|
||||
JSON_OPTION,
|
||||
CONFIRM_OPTION,
|
||||
{
|
||||
flag: "--target <path>",
|
||||
summary: "create 的备份文件路径,必须在数据目录内。",
|
||||
},
|
||||
{
|
||||
flag: "--source <path>",
|
||||
summary:
|
||||
"restore 的备份文件,必须是 <数据目录>/backups/ 下的受管备份。",
|
||||
},
|
||||
{
|
||||
...DESTRUCTIVE_CONFIRM_OPTION,
|
||||
summary:
|
||||
"restore 必须加,且要同时给出 --confirm <path>;create 不需要。",
|
||||
},
|
||||
{
|
||||
flag: "--confirm <path>",
|
||||
summary: "restore 要求逐字重复 --source 路径;不一致时不做任何改动。",
|
||||
summary:
|
||||
"restore 必须加:再写一遍 --source 的备份路径,完全一致才执行。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [...RUNTIME_EXITS, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "ui",
|
||||
summary:
|
||||
"用系统默认浏览器打开 Runtime 托管的 Web Console:先向 Core 换取一次性 loopback 登录码,再交给系统浏览器由 Core 置 HttpOnly/SameSite=Strict session;CLI 不向浏览器暴露裸 Core token。Core 未提供该合同时如实返回 ui_session_contract_unavailable(exit 4)。",
|
||||
"用系统默认浏览器打开 Runtime 托管的 Web Console:先向 Core 换取一次性 loopback 登录码,再交给系统浏览器由 Core 置 HttpOnly/SameSite=Strict session;CLI 不向浏览器暴露裸 Core token。Runtime 未运行时 exit 3;Core 未提供该合同时如实返回 ui_session_contract_unavailable(exit 4)。",
|
||||
usage: "memhub ui [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [...RUNTIME_EXITS, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
];
|
||||
|
||||
/** Old names that still run but are not listed: `path` → `scan-roots`,
|
||||
* `task` → `write status`, `graph width` → `graph-width`. */
|
||||
export const HIDDEN_COMMAND_ALIASES: Readonly<Record<string, string>> = {
|
||||
path: "scan-roots",
|
||||
task: "write",
|
||||
graph: "graph-width",
|
||||
};
|
||||
|
||||
export function findCommand(name: string): CommandDescriptor | undefined {
|
||||
return COMMANDS.find((command) => command.name === name);
|
||||
}
|
||||
|
||||
/** A listed command, or the command a hidden alias now runs as. */
|
||||
export function findCommandOrAlias(
|
||||
name: string,
|
||||
): CommandDescriptor | undefined {
|
||||
return findCommand(HIDDEN_COMMAND_ALIASES[name] ?? name);
|
||||
}
|
||||
@@ -69,7 +69,6 @@ const COMMANDS = [
|
||||
"doctor",
|
||||
"logs",
|
||||
"init",
|
||||
"setup",
|
||||
"sources",
|
||||
"sync",
|
||||
"progress",
|
||||
@@ -77,10 +76,7 @@ const COMMANDS = [
|
||||
"models",
|
||||
"memories",
|
||||
"profile",
|
||||
// C6 App BLOCK (25f4ecf): UI write operations that had no CLI equivalent.
|
||||
"calls",
|
||||
"notes",
|
||||
"graph",
|
||||
"graph-width",
|
||||
"host",
|
||||
"recall",
|
||||
"service-plan",
|
||||
@@ -93,19 +89,20 @@ const COMMANDS = [
|
||||
"onboarding",
|
||||
"help",
|
||||
"version",
|
||||
// A6 product commands. Appended, not inserted: the catalog is a stable
|
||||
// contract, so existing names must not shift position.
|
||||
"path",
|
||||
"scan-roots",
|
||||
"adapter",
|
||||
"scan",
|
||||
"write",
|
||||
"task",
|
||||
"backup",
|
||||
// `ui` is declared but blocked on a Core session contract; it is listed so
|
||||
// `help` documents it and the blocked shape is machine-readable.
|
||||
"ui",
|
||||
] as const;
|
||||
|
||||
/** Old names that still run and still answer `--help`, but are not listed. */
|
||||
const HIDDEN_ALIASES = [
|
||||
["path", "scan-roots"],
|
||||
["task", "write"],
|
||||
] as const;
|
||||
|
||||
describe("CLI-22 shared output contract", () => {
|
||||
describe("help", () => {
|
||||
it.each([["help"], ["--help"], ["-h"]])(
|
||||
@@ -146,6 +143,56 @@ describe("CLI-22 shared output contract", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(HIDDEN_ALIASES)(
|
||||
"documents the hidden alias %s as %s and keeps it out of the listing",
|
||||
async (alias, target) => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", alias], {})).toBe(0);
|
||||
expect(await run([alias, "--help"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain(`${PRODUCT} ${target}`);
|
||||
const listing = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "--json"], {})).toBe(0);
|
||||
} finally {
|
||||
listing.restore();
|
||||
}
|
||||
const names = JSON.parse(listing.stdout()).commands.map(
|
||||
(entry: { name: string }) => entry.name,
|
||||
);
|
||||
expect(names).not.toContain(alias);
|
||||
},
|
||||
);
|
||||
|
||||
it("lists no removed command", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "--json"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
const catalog = streams.stdout();
|
||||
for (const removed of [
|
||||
'"name":"setup"',
|
||||
'"name":"notes"',
|
||||
'"name":"calls"',
|
||||
'"name":"graph"',
|
||||
"--plan-file",
|
||||
"probe-plan",
|
||||
"probe-receipt",
|
||||
"models configure",
|
||||
"sources import",
|
||||
"--full-rescan",
|
||||
"--dry-run",
|
||||
"--full]",
|
||||
]) {
|
||||
expect(catalog).not.toContain(removed);
|
||||
}
|
||||
});
|
||||
|
||||
it("never executes the documented command", async () => {
|
||||
for (const command of COMMANDS) {
|
||||
for (const argv of [
|
||||
@@ -201,27 +248,63 @@ describe("CLI-22 shared output contract", () => {
|
||||
expect(payload.commands).toBeUndefined();
|
||||
});
|
||||
|
||||
it("documents the destructive restore confirmation explicitly", async () => {
|
||||
it("documents the one --yes convention for destructive commands", async () => {
|
||||
const general = captureStreams();
|
||||
try {
|
||||
expect(await run(["help"], {})).toBe(0);
|
||||
} finally {
|
||||
general.restore();
|
||||
}
|
||||
expect(general.stdout()).toContain("确认约定");
|
||||
for (const [command, usage] of [
|
||||
[
|
||||
"backup",
|
||||
"memhub backup restore --source <path> [--yes --confirm <path>]",
|
||||
],
|
||||
["memories", "memhub memories delete <memory-id>"],
|
||||
["models", "memhub models remove <profile-id> [--yes]"],
|
||||
[
|
||||
"delete-data",
|
||||
"memhub delete-data [--data-dir <path>] [--yes --confirm <path>]",
|
||||
],
|
||||
["graph-width", "[--yes]"],
|
||||
] as const) {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", command], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain(usage);
|
||||
// Only the two commands with no way back repeat the target path.
|
||||
if (command !== "backup" && command !== "delete-data") {
|
||||
expect(streams.stdout()).not.toContain("--confirm <path>");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("says backup does not include the memories themselves", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "backup"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain(
|
||||
"memhub backup restore --source <path> --yes --confirm <path>",
|
||||
);
|
||||
expect(streams.stdout()).toContain("逐字重复 --source");
|
||||
expect(streams.stdout()).toContain("不包含记忆本身");
|
||||
});
|
||||
|
||||
it("does not attach restore's confirmation flag to path", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "path"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
it("does not promise a rollback after the migration started", async () => {
|
||||
for (const command of ["install", "upgrade"]) {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", command], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain("迁移开始后失败保留新版本");
|
||||
expect(streams.stdout()).not.toContain("失败时恢复旧版本");
|
||||
expect(streams.stdout()).not.toContain("失败时回滚");
|
||||
}
|
||||
expect(streams.stdout()).not.toContain("--confirm <path>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -545,22 +628,23 @@ describe("documented commands", () => {
|
||||
models: [
|
||||
"list",
|
||||
"show",
|
||||
"plan",
|
||||
"configure",
|
||||
"add",
|
||||
"test",
|
||||
"receipt",
|
||||
"probe-plan",
|
||||
"probe",
|
||||
"probe-receipt",
|
||||
"edit",
|
||||
"activate",
|
||||
"remove",
|
||||
],
|
||||
// Source processing is one Freeze/Thaw switch under `sync`; the obsolete
|
||||
// independent read/write `sources access` command must not return here.
|
||||
sources: ["list", "import", "receipt"],
|
||||
sources: ["list", "connect", "receipt"],
|
||||
jobs: ["list", "show", "start", "<pause|resume|cancel>"],
|
||||
// `sync <pause|resume>` is documented as one alternation, so it is
|
||||
// checked as an entry rather than as two literal subcommands.
|
||||
sync: ["status", "<pause|resume>"],
|
||||
memories: ["list", "show", "update", "delete"],
|
||||
memories: ["list", "show", "add", "add-status", "update", "delete"],
|
||||
"scan-roots": ["list", "add", "remove"],
|
||||
write: ["submit", "status"],
|
||||
host: ["list", "status", "install", "uninstall", "sync"],
|
||||
};
|
||||
|
||||
|
||||
@@ -133,6 +133,20 @@ describe("unified context search", () => {
|
||||
expect(output).not.toMatch(/^- raw |memory-1|raw-1|采用状态/m);
|
||||
});
|
||||
|
||||
it("labels raw hits as original-conversation excerpts, not memories", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["recall", "synthetic question"])).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
const output = streams.stdout();
|
||||
expect(output).toContain(
|
||||
"- [原始对话摘录 · codex] Synthetic raw citation.",
|
||||
);
|
||||
expect(output).toContain("- [记忆] Synthetic SDK memory.");
|
||||
});
|
||||
|
||||
it("queries the SDK context endpoint directly and preserves item ids", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ContextSearchDocument {
|
||||
| "unavailable"
|
||||
| "denied"
|
||||
| "not_found"
|
||||
| "rejected"
|
||||
| "invalid";
|
||||
code?: string;
|
||||
request_id?: string;
|
||||
@@ -97,6 +98,9 @@ export function renderContextText(document: ContextSearchDocument): string {
|
||||
if (document.state === "unavailable") return "暂时无法查记忆,请稍后重试。\n";
|
||||
if (document.state === "denied") return "当前应用没有查记忆权限。\n";
|
||||
if (document.state === "invalid") return "查记忆返回了无法识别的结果。\n";
|
||||
if (document.state === "rejected") {
|
||||
return `查记忆被拒绝(${document.code}),请检查参数。\n`;
|
||||
}
|
||||
const lines = [
|
||||
document.state === "empty"
|
||||
? "没有查到相关记忆。"
|
||||
@@ -104,8 +108,14 @@ export function renderContextText(document: ContextSearchDocument): string {
|
||||
? "查到以下内容,部分内容还在整理中:"
|
||||
: "查到以下相关内容:",
|
||||
];
|
||||
// A raw hit is an excerpt of an original conversation, not a distilled
|
||||
// memory; say which one each line is so it is not read as a conclusion.
|
||||
for (const item of document.items) {
|
||||
lines.push(`- ${String(item.content)}`);
|
||||
const label =
|
||||
item.kind === "raw"
|
||||
? `[原始对话摘录 · ${String(item.source)}]`
|
||||
: "[记忆]";
|
||||
lines.push(`- ${label} ${String(item.content)}`);
|
||||
}
|
||||
if (document.truncated) lines.push("结果已按请求预算截断。");
|
||||
return `${lines.join("\n")}\n`;
|
||||
|
||||
@@ -18,6 +18,9 @@ const READ_ONLY_PATHS = [
|
||||
"/api/v2/user-profile",
|
||||
] as const;
|
||||
const SDK_MEMORY_DETAIL = /^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}$/;
|
||||
/** Status of one `POST /api/v2/memories` addition, by its request id. */
|
||||
const MEMORY_ADDITION =
|
||||
/^\/api\/v2\/memories\/additions\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/** Contract (C0, H25): the one-time loopback code exchange for `memhub ui`.
|
||||
* It is a POST because it mints a short-lived code; the CLI sends the bearer
|
||||
@@ -79,9 +82,15 @@ export const RESERVED_MEMORY_SEGMENTS = ["ask", "graph"] as const;
|
||||
/** A response larger than this is refused before it is buffered. */
|
||||
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** `rejected` is Core refusing the request itself (400/409/422: a bad filter,
|
||||
* an ambiguous id). It is not "Core is busy" — only a transport failure or a
|
||||
* 5xx is `unavailable`. */
|
||||
export type ReadOutcome<T> =
|
||||
| { state: "ok"; data: T }
|
||||
| { state: "denied" | "unavailable" | "not_found"; code: string };
|
||||
| {
|
||||
state: "denied" | "unavailable" | "not_found" | "rejected";
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type CoreReader = <T>(
|
||||
path: string,
|
||||
@@ -140,10 +149,27 @@ export function createCoreReader(attachment: {
|
||||
if (response.status === 404) {
|
||||
return { state: "not_found" as const, code: "http_404" };
|
||||
}
|
||||
if ([400, 409, 422].includes(response.status)) {
|
||||
return {
|
||||
state: "rejected" as const,
|
||||
code: detailCode(response.data, response.status),
|
||||
};
|
||||
}
|
||||
return { state: "unavailable" as const, code: `http_${response.status}` };
|
||||
};
|
||||
}
|
||||
|
||||
/** Core's stable `detail` code when it sent one, else the bare status. */
|
||||
function detailCode(value: unknown, status: number): string {
|
||||
const detail =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as { detail?: unknown }).detail
|
||||
: undefined;
|
||||
return typeof detail === "string" && /^[a-z0-9_.:-]{1,128}$/.test(detail)
|
||||
? detail
|
||||
: `http_${status}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context search is semantically read-only but intentionally uses POST so the
|
||||
* private query and filters are carried in a bounded body, never in a URL.
|
||||
@@ -192,6 +218,7 @@ export function isReadOnlyPath(path: string): boolean {
|
||||
if ((READ_ONLY_PATHS as readonly string[]).includes(path)) return true;
|
||||
if (IMPORT_JOB.test(path)) return true;
|
||||
if (OPERATION_RECEIPT.test(path)) return true;
|
||||
if (MEMORY_ADDITION.test(path)) return true;
|
||||
if (!SDK_MEMORY_DETAIL.test(path)) return false;
|
||||
const segment = path.slice("/api/v2/memories/".length);
|
||||
return !(RESERVED_MEMORY_SEGMENTS as readonly string[]).includes(segment);
|
||||
@@ -199,8 +226,10 @@ export function isReadOnlyPath(path: string): boolean {
|
||||
|
||||
/** A write accepted by the unified Memory Server entry.
|
||||
*
|
||||
* `request_id` is what the caller keeps and queries later. Retrying is unsafe:
|
||||
* HY has no idempotency key, so a resubmit duplicates the memory.
|
||||
* `request_id` is the idempotency key: Core keys the write on it, so a retry
|
||||
* with the same id and the same messages is answered from the first write, and
|
||||
* the same id with different messages is refused (409). A retry without the
|
||||
* original id is a new write.
|
||||
*/
|
||||
export interface MemoryWriteReceipt {
|
||||
request_id: string;
|
||||
@@ -267,6 +296,11 @@ function baseUrlOf(attachment: RuntimeAttachmentLike): string {
|
||||
return `http://${attachment.host}:${attachment.port}`;
|
||||
}
|
||||
|
||||
export type MemoryWriteOutcome =
|
||||
| { state: "accepted"; receipt: MemoryWriteReceipt }
|
||||
| { state: "rejected"; code: string; http_status: number }
|
||||
| { state: "unknown"; code: string };
|
||||
|
||||
export async function submitMemoryWrite(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
request: {
|
||||
@@ -277,28 +311,48 @@ export async function submitMemoryWrite(
|
||||
memory_at?: string;
|
||||
priority?: "live" | "normal";
|
||||
},
|
||||
): Promise<MemoryWriteReceipt> {
|
||||
const response = await axios.post<MemoryWriteReceipt>(
|
||||
`${baseUrlOf(attachment)}/api/v2/memory-writes`,
|
||||
{
|
||||
request_id: request.request_id,
|
||||
session_id: request.session,
|
||||
source_id: request.source,
|
||||
messages: [...request.messages],
|
||||
...(request.memory_at === undefined
|
||||
? {}
|
||||
: { memory_at: request.memory_at }),
|
||||
...(request.priority === undefined ? {} : { priority: request.priority }),
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 15_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
): Promise<MemoryWriteOutcome> {
|
||||
let response: { status: number; data: unknown };
|
||||
try {
|
||||
response = await axios.post<MemoryWriteReceipt>(
|
||||
`${baseUrlOf(attachment)}/api/v2/memory-writes`,
|
||||
{
|
||||
request_id: request.request_id,
|
||||
session_id: request.session,
|
||||
source_id: request.source,
|
||||
messages: [...request.messages],
|
||||
...(request.memory_at === undefined
|
||||
? {}
|
||||
: { memory_at: request.memory_at }),
|
||||
...(request.priority === undefined
|
||||
? {}
|
||||
: { priority: request.priority }),
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 15_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
validateStatus: () => true,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// The request may or may not have reached Core; the same request id makes
|
||||
// a retry safe.
|
||||
return { state: "unknown", code: "transport_result_unknown" };
|
||||
}
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return { state: "accepted", receipt: response.data as MemoryWriteReceipt };
|
||||
}
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return {
|
||||
state: "rejected",
|
||||
code: detailCode(response.data, response.status),
|
||||
http_status: response.status,
|
||||
};
|
||||
}
|
||||
return { state: "unknown", code: `http_${response.status}_result_unknown` };
|
||||
}
|
||||
|
||||
/** Ask Runtime to scan one source now, returning the import job it started.
|
||||
@@ -495,48 +549,52 @@ export async function readScanRoots(
|
||||
}
|
||||
}
|
||||
|
||||
/** A direct Core write whose refusal carries Core's stable code. */
|
||||
export type CoreWriteResult =
|
||||
| { ok: true; data: unknown }
|
||||
| { ok: false; code: string };
|
||||
|
||||
async function coreWrite(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
method: "post" | "delete",
|
||||
pathname: string,
|
||||
body: Record<string, unknown>,
|
||||
timeout = 10_000,
|
||||
): Promise<CoreWriteResult> {
|
||||
let response: { status: number; data: unknown };
|
||||
try {
|
||||
response = await axios.request({
|
||||
method,
|
||||
url: `${baseUrlOf(attachment)}${pathname}`,
|
||||
data: body,
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, code: "transport_failed" };
|
||||
}
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return { ok: true, data: response.data };
|
||||
}
|
||||
return { ok: false, code: detailCode(response.data, response.status) };
|
||||
}
|
||||
|
||||
export async function addScanRoot(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
body: { root: string; source?: string; recursive?: boolean },
|
||||
): Promise<unknown | null> {
|
||||
try {
|
||||
const response = await axios.post<unknown>(
|
||||
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
|
||||
body,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { _error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
): Promise<CoreWriteResult> {
|
||||
return coreWrite(attachment, "post", "/api/v1/scan-roots", body);
|
||||
}
|
||||
|
||||
export async function removeScanRoot(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
body: { root: string },
|
||||
): Promise<unknown | null> {
|
||||
try {
|
||||
const response = await axios.delete<unknown>(
|
||||
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
data: body,
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { _error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
): Promise<CoreWriteResult> {
|
||||
return coreWrite(attachment, "delete", "/api/v1/scan-roots", body);
|
||||
}
|
||||
|
||||
export async function readAdapters(
|
||||
@@ -562,21 +620,12 @@ export async function readAdapters(
|
||||
export async function probeAdapter(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
source: string,
|
||||
): Promise<unknown | null> {
|
||||
try {
|
||||
const response = await axios.post<unknown>(
|
||||
`${baseUrlOf(attachment)}/api/v1/adapters/probe`,
|
||||
{ source },
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 15_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { _error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
): Promise<CoreWriteResult> {
|
||||
return coreWrite(
|
||||
attachment,
|
||||
"post",
|
||||
"/api/v1/adapters/probe",
|
||||
{ source },
|
||||
15_000,
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,11 @@ const WRITE_PATHS = [
|
||||
"/api/v1/sources/connect",
|
||||
"/api/v1/model-profiles",
|
||||
] as const;
|
||||
const MEMORY_WRITE_PATH =
|
||||
/^\/api\/v1\/memories(?:\/[A-Za-z0-9][\w.-]{0,127}\/(?:correct|scopes|actions\/delete))?$/;
|
||||
const SDK_MEMORY_PATH = /^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}$/;
|
||||
/** `POST /api/v2/memories` adds one memory (the app's 添加记忆). */
|
||||
const MEMORY_ADD_PATH = "/api/v2/memories";
|
||||
/** One SDK memory; delete names its namespace in the query string. */
|
||||
const SDK_MEMORY_PATH =
|
||||
/^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}(?:\?kind=(?:chat|experience|fact)(?:&variant=(?:working|coding))?)?$/;
|
||||
const MODEL_PROBE_PATH =
|
||||
/^\/api\/v1\/model-profiles\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/probe$/;
|
||||
const MODEL_PROFILE_PATH =
|
||||
@@ -22,18 +24,7 @@ const SOURCE_REVOKE_PATH =
|
||||
const SOURCE_IMPORT_JOB_PATH = "/api/v1/import-jobs";
|
||||
const IMPORT_JOB_ACTION_PATH =
|
||||
/^\/api\/v1\/import-jobs\/[A-Za-z0-9][\w.-]{0,127}\/(?:pause|resume|cancel)$/;
|
||||
const CALL_EVENTS_CLEAR_PATH = "/api/v1/call-events/clear";
|
||||
const CALL_EVENTS_CLEAR_ALL_PATH = "/api/v1/call-events/clear-all";
|
||||
const IMPORT_FILE_COMMIT =
|
||||
/^\/api\/v1\/import-files\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/commit$/;
|
||||
const IMPORT_FILE =
|
||||
/^\/api\/v1\/import-files\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const MEMORY_NOTES_PATH = "/api/v1/memory-notes";
|
||||
const MEMORY_NOTE_PROCESS =
|
||||
/^\/api\/v1\/memory-notes\/[A-Za-z0-9][\w.:-]{0,127}\/process$/;
|
||||
const EXTRACTION_SETTINGS_PATH = "/api/v1/extraction-settings";
|
||||
const GRAPH_WIDTH_PATH = "/api/v1/graph-width";
|
||||
const MANUAL_EXTRACT_PATH = "/api/v1/memory-processing/manual-extract";
|
||||
const ONBOARDING_COMPLETE_PATH = "/api/v1/onboarding/complete";
|
||||
const MAX_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
@@ -70,18 +61,6 @@ export interface CorePatchOperations {
|
||||
): Promise<OperationOutcome<T>>;
|
||||
}
|
||||
|
||||
export interface CoreUploadOperations {
|
||||
uploadImportFile<T>(
|
||||
input: {
|
||||
bytes: Uint8Array;
|
||||
filename: string;
|
||||
inputKind: "raw_conversation" | "memory_content";
|
||||
operationId: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<OperationOutcome<T>>;
|
||||
}
|
||||
|
||||
export function createCoreOperations(attachment: {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -90,8 +69,7 @@ export function createCoreOperations(attachment: {
|
||||
}): CoreOperations &
|
||||
CoreDeleteOperations &
|
||||
CorePutOperations &
|
||||
CorePatchOperations &
|
||||
CoreUploadOperations {
|
||||
CorePatchOperations {
|
||||
const client = axios.create({
|
||||
baseURL: `http://${attachment.host}:${attachment.port}`,
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
@@ -102,18 +80,6 @@ export function createCoreOperations(attachment: {
|
||||
maxBodyLength: 128 * 1024,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
// Uploads carry up to 128 MiB of raw octet-stream, so they need a separate
|
||||
// body limit and a deadline that leaves room for parsing on the Core side.
|
||||
const uploadClient = axios.create({
|
||||
baseURL: `http://${attachment.host}:${attachment.port}`,
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 120_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_BYTES,
|
||||
maxBodyLength: 128 * 1024 * 1024,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
return {
|
||||
async post<T>(
|
||||
path: string,
|
||||
@@ -123,19 +89,13 @@ export function createCoreOperations(attachment: {
|
||||
if (
|
||||
!(WRITE_PATHS as readonly string[]).includes(path) &&
|
||||
path !== SOURCE_IMPORT_JOB_PATH &&
|
||||
!MEMORY_WRITE_PATH.test(path) &&
|
||||
path !== MEMORY_ADD_PATH &&
|
||||
!MODEL_PROBE_PATH.test(path) &&
|
||||
!SOURCE_SYNC_PATH.test(path) &&
|
||||
!IMPORT_JOB_ACTION_PATH.test(path) &&
|
||||
!SOURCE_AUTHORIZE_PATH.test(path) &&
|
||||
!SOURCE_REVOKE_PATH.test(path) &&
|
||||
path !== CALL_EVENTS_CLEAR_PATH &&
|
||||
path !== CALL_EVENTS_CLEAR_ALL_PATH &&
|
||||
!IMPORT_FILE_COMMIT.test(path) &&
|
||||
path !== MEMORY_NOTES_PATH &&
|
||||
!MEMORY_NOTE_PROCESS.test(path) &&
|
||||
path !== GRAPH_WIDTH_PATH &&
|
||||
path !== MANUAL_EXTRACT_PATH &&
|
||||
path !== ONBOARDING_COMPLETE_PATH &&
|
||||
!MODEL_PROFILE_ACTIVATE.test(path)
|
||||
) {
|
||||
@@ -171,7 +131,7 @@ export function createCoreOperations(attachment: {
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
if (path !== EXTRACTION_SETTINGS_PATH && !SDK_MEMORY_PATH.test(path)) {
|
||||
if (!SDK_MEMORY_PATH.test(path) || path.includes("?")) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
}
|
||||
let response: { status: number; data: unknown };
|
||||
@@ -233,7 +193,7 @@ export function createCoreOperations(attachment: {
|
||||
};
|
||||
},
|
||||
async delete<T>(path: string, signal?: AbortSignal) {
|
||||
if (!IMPORT_FILE.test(path) && !SDK_MEMORY_PATH.test(path)) {
|
||||
if (!SDK_MEMORY_PATH.test(path) && !MODEL_PROFILE_PATH.test(path)) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
}
|
||||
let response: { status: number; data: unknown };
|
||||
@@ -257,51 +217,6 @@ export function createCoreOperations(attachment: {
|
||||
code: `http_${response.status}_result_unknown`,
|
||||
};
|
||||
},
|
||||
async uploadImportFile<T>(
|
||||
input: {
|
||||
bytes: Uint8Array;
|
||||
filename: string;
|
||||
inputKind: "raw_conversation" | "memory_content";
|
||||
operationId: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const query = new URLSearchParams({
|
||||
filename: input.filename,
|
||||
input_kind: input.inputKind,
|
||||
operation_id: input.operationId,
|
||||
});
|
||||
let response: { status: number; data: unknown };
|
||||
try {
|
||||
response = await uploadClient.post(
|
||||
`/api/v1/import-files?${query.toString()}`,
|
||||
input.bytes,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(input.bytes.byteLength),
|
||||
},
|
||||
...(signal ? { signal } : {}),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return { state: "unknown", code: "transport_result_unknown" };
|
||||
}
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return { state: "committed", data: response.data as T };
|
||||
}
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return {
|
||||
state: "rejected",
|
||||
code: safeErrorCode(response.data, response.status),
|
||||
http_status: response.status,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "unknown",
|
||||
code: `http_${response.status}_result_unknown`,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,39 +23,26 @@ function accountHomeDirectory(): string | undefined {
|
||||
async function main(): Promise<number> {
|
||||
const arguments_ = process.argv.slice(2);
|
||||
const command = arguments_[0];
|
||||
const action = arguments_[1];
|
||||
// A preview (`--yes` absent) must not create or write any diagnostic file:
|
||||
// the contract is that it only emits a plan. Diagnostics for these commands
|
||||
// are initialized only once the caller has confirmed the mutation.
|
||||
const confirmed = arguments_.includes("--yes")
|
||||
? true
|
||||
: arguments_.includes("--confirm");
|
||||
const confirmed = arguments_.includes("--yes");
|
||||
const installLike = [
|
||||
"install",
|
||||
"upgrade",
|
||||
"service-install",
|
||||
"service-uninstall",
|
||||
].includes(command ?? "");
|
||||
const planFileApply =
|
||||
(command === "sources" && action === "import") ||
|
||||
(command === "models" && (action === "configure" || action === "probe")) ||
|
||||
(command === "processing" && action === "apply") ||
|
||||
(command === "memories" && action === "apply");
|
||||
const isPreview = (installLike || planFileApply) && !confirmed;
|
||||
const mutating =
|
||||
[
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"install",
|
||||
"upgrade",
|
||||
"service-install",
|
||||
"service-uninstall",
|
||||
].includes(command ?? "") ||
|
||||
(command === "sources" && action === "import") ||
|
||||
(command === "models" && ["configure", "probe"].includes(action ?? "")) ||
|
||||
(command === "processing" && action === "apply") ||
|
||||
(command === "memories" && action === "apply");
|
||||
const isPreview = installLike && !confirmed;
|
||||
const mutating = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"install",
|
||||
"upgrade",
|
||||
"service-install",
|
||||
"service-uninstall",
|
||||
].includes(command ?? "");
|
||||
let diagnostics: DiagnosticLogger | null = null;
|
||||
if (mutating && !isPreview) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* `memhub graph-width`: the user's decision when the embedding width changed
|
||||
* and the memory graph no longer matches it (`POST /api/v1/graph-width`).
|
||||
*
|
||||
* `keep_existing` and `export_first` change nothing irreversible and run
|
||||
* directly. `rebuild_with_new` archives the current graph and rebuilds it, so
|
||||
* the CLI asks for `--yes` before sending it.
|
||||
*/
|
||||
|
||||
import { parseGraphWidthDecisionReceipt } from "@one-memory/operations";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
export const GRAPH_WIDTH_DECISIONS = [
|
||||
"keep_existing",
|
||||
"rebuild_with_new",
|
||||
"export_first",
|
||||
] as const;
|
||||
export type GraphWidthDecision = (typeof GRAPH_WIDTH_DECISIONS)[number];
|
||||
|
||||
/** The one decision that cannot be taken back. */
|
||||
export function graphWidthDecisionIsDestructive(
|
||||
decision: GraphWidthDecision,
|
||||
): boolean {
|
||||
return decision === "rebuild_with_new";
|
||||
}
|
||||
|
||||
export type GraphWidthDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "graph-width";
|
||||
state: "committed";
|
||||
decision: GraphWidthDecision;
|
||||
receipt: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "graph-width";
|
||||
state: "rejected" | "unknown";
|
||||
decision: GraphWidthDecision;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export async function decideGraphWidth(
|
||||
decision: GraphWidthDecision,
|
||||
operations: CoreOperations,
|
||||
): Promise<GraphWidthDocument> {
|
||||
const base = {
|
||||
schema_version: 1 as const,
|
||||
product: PRODUCT,
|
||||
command: "graph-width" as const,
|
||||
decision,
|
||||
};
|
||||
const result = await operations.post<unknown>("/api/v1/graph-width", {
|
||||
decision,
|
||||
});
|
||||
if (result.state !== "committed") {
|
||||
return { ...base, state: result.state, code: result.code };
|
||||
}
|
||||
const receipt = parseGraphWidthDecisionReceipt(result.data);
|
||||
if (receipt === null) {
|
||||
return { ...base, state: "unknown", code: "graph_width_receipt_invalid" };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: "committed",
|
||||
receipt: {
|
||||
archived_path: receipt.archived_path ?? null,
|
||||
exported_path: receipt.exported_path ?? null,
|
||||
mismatch: receipt.mismatch ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function graphWidthExitCode(document: GraphWidthDocument): number {
|
||||
return document.state === "committed" ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderGraphWidthText(document: GraphWidthDocument): string {
|
||||
if (document.state === "committed") {
|
||||
const lines = [`已执行图谱宽度决定:${document.decision}`];
|
||||
if (document.receipt.exported_path) {
|
||||
lines.push(`导出:${document.receipt.exported_path}`);
|
||||
}
|
||||
if (document.receipt.archived_path) {
|
||||
lines.push(`旧图谱已归档:${document.receipt.archived_path}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
return `图谱宽度决定未完成:${document.state}(${document.code})\n`;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ImportJob, ModelProfile } from "@one-memory/contracts";
|
||||
import {
|
||||
buildModelProfileCreateRequest,
|
||||
@@ -26,45 +26,7 @@ const PROVIDERS = [
|
||||
"local_onnx",
|
||||
] as const;
|
||||
|
||||
interface SourceImportPlanBody {
|
||||
schema_version: 1;
|
||||
kind: "source_import";
|
||||
request_id: string;
|
||||
source_ids: string[];
|
||||
source_roots: Record<string, string>;
|
||||
source_candidate_revisions: Record<string, number>;
|
||||
confirm_environment_overrides: boolean;
|
||||
full_rescan: false;
|
||||
onboarding: true;
|
||||
}
|
||||
|
||||
export type SourceImportPlan = SourceImportPlanBody & { digest: string };
|
||||
|
||||
interface ModelCreatePlanBody {
|
||||
schema_version: 1;
|
||||
kind: "model_profile_create";
|
||||
operation_id: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ModelCreatePlan = ModelCreatePlanBody & { digest: string };
|
||||
|
||||
/** A removal only needs an id: there is no config to review, and inventing one
|
||||
* to fit the create shape would misrepresent what the command does.
|
||||
*/
|
||||
export interface ModelRemovePlan {
|
||||
schema_version: 1;
|
||||
kind: "model_profile_remove";
|
||||
target: string;
|
||||
}
|
||||
|
||||
export type FlowDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "preview";
|
||||
plan: SourceImportPlan | ModelCreatePlan | ModelRemovePlan;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
@@ -133,10 +95,24 @@ function classifySourceImport(
|
||||
return committed(operationId, receipt);
|
||||
}
|
||||
|
||||
export async function planSourceImport(
|
||||
/**
|
||||
* Connect sources and import their history in one direct request.
|
||||
*
|
||||
* Reads the current candidates, refuses a source whose location comes from an
|
||||
* environment override unless the caller confirmed overrides, and posts one
|
||||
* `/sources/connect` (grant + history import + live sync). `requestId` is the
|
||||
* idempotency key: re-running with the same id reads the first receipt.
|
||||
*/
|
||||
export async function connectSourcesWithImport(
|
||||
reader: CoreReader,
|
||||
sourceIds: readonly string[],
|
||||
operations: CoreOperations,
|
||||
input: {
|
||||
sourceIds: readonly string[];
|
||||
confirmOverrides: boolean;
|
||||
requestId?: string;
|
||||
},
|
||||
): Promise<FlowDocument> {
|
||||
const sourceIds = input.sourceIds;
|
||||
if (
|
||||
sourceIds.length < 1 ||
|
||||
sourceIds.length > 32 ||
|
||||
@@ -145,6 +121,8 @@ export async function planSourceImport(
|
||||
) {
|
||||
return invalid("invalid_source_selection");
|
||||
}
|
||||
const requestId = input.requestId ?? randomUUID();
|
||||
if (!UUID.test(requestId)) return invalid("invalid_request_id");
|
||||
const outcome = await reader<unknown>("/api/v1/sources");
|
||||
if (outcome.state !== "ok") return fromReadFailure(outcome);
|
||||
const candidates = validateSourceConnectionCandidates(outcome.data);
|
||||
@@ -156,69 +134,59 @@ export async function planSourceImport(
|
||||
return invalid("source_unavailable");
|
||||
}
|
||||
const items = selected.map(([candidate]) => candidate);
|
||||
const body: SourceImportPlanBody = {
|
||||
schema_version: 1,
|
||||
kind: "source_import",
|
||||
request_id: randomUUID(),
|
||||
source_ids: [...sourceIds],
|
||||
source_roots: Object.fromEntries(
|
||||
items.map((item) => [item.source_id, item.candidate_root_id]),
|
||||
),
|
||||
source_candidate_revisions: Object.fromEntries(
|
||||
items.flatMap((item) =>
|
||||
item.candidate_revision === undefined
|
||||
? []
|
||||
: [[item.source_id, item.candidate_revision]],
|
||||
),
|
||||
),
|
||||
confirm_environment_overrides: items.some(
|
||||
(item) => item.requires_override_confirmation,
|
||||
),
|
||||
full_rescan: false,
|
||||
onboarding: true,
|
||||
};
|
||||
return preview(withDigest(body));
|
||||
}
|
||||
|
||||
export async function executeSourceImport(
|
||||
value: unknown,
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
): Promise<FlowDocument> {
|
||||
const plan = validateSourcePlan(value);
|
||||
if (!plan) return invalid("source_plan_invalid");
|
||||
const needsOverride = items
|
||||
.filter((item) => item.requires_override_confirmation)
|
||||
.map((item) => item.source_id);
|
||||
if (needsOverride.length > 0 && !input.confirmOverrides) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "rejected",
|
||||
operation_id: requestId,
|
||||
code: "environment_override_confirmation_required",
|
||||
receipt: { override_required_source_ids: needsOverride },
|
||||
};
|
||||
}
|
||||
// The shared builder owns the connect body's field set and coalescing, so
|
||||
// the CLI and the Web console send the same request by construction.
|
||||
let request: ReturnType<typeof buildSourceImportRequest>;
|
||||
try {
|
||||
request = buildSourceImportRequest({
|
||||
sourceIds: plan.source_ids,
|
||||
sourceRoots: plan.source_roots,
|
||||
sourceCandidateRevisions: plan.source_candidate_revisions,
|
||||
confirmEnvironmentOverrides: plan.confirm_environment_overrides,
|
||||
fullRescan: plan.full_rescan,
|
||||
onboarding: plan.onboarding,
|
||||
sourceIds: [...sourceIds],
|
||||
sourceRoots: Object.fromEntries(
|
||||
items.map((item) => [item.source_id, item.candidate_root_id]),
|
||||
),
|
||||
sourceCandidateRevisions: Object.fromEntries(
|
||||
items.flatMap((item) =>
|
||||
item.candidate_revision === undefined
|
||||
? []
|
||||
: [[item.source_id, item.candidate_revision]],
|
||||
),
|
||||
),
|
||||
confirmEnvironmentOverrides: needsOverride.length > 0,
|
||||
fullRescan: false,
|
||||
onboarding: true,
|
||||
enableSync: true,
|
||||
requestId: plan.request_id,
|
||||
requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof SourceImportInputError) {
|
||||
return invalid("source_plan_invalid");
|
||||
return invalid("invalid_source_selection");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await operations.post<unknown>(request.path, request.body);
|
||||
if (result.state === "rejected") {
|
||||
return rejected(plan.request_id, result.code);
|
||||
return rejected(requestId, result.code);
|
||||
}
|
||||
if (result.state === "committed") {
|
||||
const receipt = validateSourceReceipt(result.data, plan.request_id);
|
||||
if (!receipt) return unknown(plan.request_id, "source_receipt_invalid");
|
||||
return classifySourceImport(plan.request_id, receipt);
|
||||
const receipt = validateSourceReceipt(result.data, requestId);
|
||||
if (!receipt) return unknown(requestId, "source_receipt_invalid");
|
||||
return classifySourceImport(requestId, receipt);
|
||||
}
|
||||
const recovered = await readSourceReceipt(reader, plan.request_id);
|
||||
const recovered = await readSourceReceipt(reader, requestId);
|
||||
if (recovered.state === "unknown" || recovered.state === "not_found") {
|
||||
return unknown(plan.request_id, result.code);
|
||||
return unknown(requestId, result.code);
|
||||
}
|
||||
// A recovered receipt must be classified the same way as a fresh one: an
|
||||
// all-rejected or all-busy replay is still not success. Only a terminal
|
||||
@@ -262,26 +230,23 @@ export async function readSourceReceipt(
|
||||
return committed(requestId, receipt);
|
||||
}
|
||||
|
||||
export function planModelProfile(value: unknown): FlowDocument {
|
||||
const config = validateModelConfig(value);
|
||||
if (!config) return invalid("model_config_invalid");
|
||||
const body: ModelCreatePlanBody = {
|
||||
schema_version: 1,
|
||||
kind: "model_profile_create",
|
||||
operation_id: randomUUID(),
|
||||
config,
|
||||
};
|
||||
return preview(withDigest(body));
|
||||
}
|
||||
|
||||
export async function executeModelProfile(
|
||||
/**
|
||||
* Create a model profile directly from a bounded config (never containing the
|
||||
* key) plus an optional key read from a controlled file.
|
||||
*
|
||||
* `operationId` is Core's creation idempotency key; the receipt can be read
|
||||
* later with `readModelReceipt`.
|
||||
*/
|
||||
export async function createModelProfile(
|
||||
value: unknown,
|
||||
apiKey: string | undefined,
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
operationId: string = randomUUID(),
|
||||
): Promise<FlowDocument> {
|
||||
const plan = validateModelPlan(value);
|
||||
if (!plan) return invalid("model_plan_invalid");
|
||||
const config = validateModelConfig(value);
|
||||
if (!config) return invalid("model_config_invalid");
|
||||
if (!UUID.test(operationId)) return invalid("invalid_operation_id");
|
||||
// The shared create builder owns the body's allow-list, bounds and the
|
||||
// creation_operation_id/api_key handling, so the CLI and the Web console
|
||||
// send the same create request.
|
||||
@@ -289,31 +254,31 @@ export async function executeModelProfile(
|
||||
try {
|
||||
request = buildModelProfileCreateRequest(
|
||||
{
|
||||
...(plan.config as Record<string, unknown>),
|
||||
...config,
|
||||
...(apiKey === undefined ? {} : { api_key: apiKey }),
|
||||
} as Parameters<typeof buildModelProfileCreateRequest>[0],
|
||||
plan.operation_id,
|
||||
operationId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ModelProfileInputError) {
|
||||
return invalid("model_plan_invalid");
|
||||
return invalid("model_config_invalid");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await operations.post<unknown>(request.path, request.body);
|
||||
if (result.state === "rejected") {
|
||||
return rejected(plan.operation_id, result.code);
|
||||
return rejected(operationId, result.code);
|
||||
}
|
||||
if (result.state === "committed") {
|
||||
const profile = validateModelProfile(result.data);
|
||||
return profile
|
||||
? committed(plan.operation_id, projectProfile(profile))
|
||||
: unknown(plan.operation_id, "model_receipt_invalid");
|
||||
? committed(operationId, projectProfile(profile))
|
||||
: unknown(operationId, "model_receipt_invalid");
|
||||
}
|
||||
const recovered = await readModelReceipt(reader, plan.operation_id);
|
||||
const recovered = await readModelReceipt(reader, operationId);
|
||||
return recovered.state === "committed"
|
||||
? { ...recovered, state: "recovered" }
|
||||
: unknown(plan.operation_id, result.code);
|
||||
: unknown(operationId, result.code);
|
||||
}
|
||||
|
||||
export async function readModelReceipt(
|
||||
@@ -362,27 +327,6 @@ export function parseApiKey(bytes: Buffer | null): string | undefined {
|
||||
}
|
||||
|
||||
export function renderFlowText(document: FlowDocument): string {
|
||||
if (document.state === "preview") {
|
||||
const plan = document.plan;
|
||||
// A removal has no config digest or operation id to confirm: the target id
|
||||
// *is* the thing being reviewed. Rendering fields it does not have would
|
||||
// print "undefined".
|
||||
if (plan.kind === "model_profile_remove") {
|
||||
return [
|
||||
`预览:${plan.kind}`,
|
||||
`目标配置:${plan.target}`,
|
||||
"未执行任何写操作。加 --confirm 才会真正删除。",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
`预览:${plan.kind}`,
|
||||
`操作 ID:${"request_id" in plan ? plan.request_id : plan.operation_id}`,
|
||||
`确认摘要:${plan.digest}`,
|
||||
"未执行任何写操作。请把 --json 输出保存为 plan 文件,再用对应命令加 --confirm。",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
const lines = [
|
||||
`操作状态:${document.state}`,
|
||||
`操作 ID:${document.operation_id ?? "无"}`,
|
||||
@@ -407,98 +351,20 @@ export function renderFlowText(document: FlowDocument): string {
|
||||
if (document.state === "partial" && Array.isArray(accepted)) {
|
||||
lines.push(` 已接入:${accepted.join("、")}`);
|
||||
}
|
||||
const overrides = document.receipt.override_required_source_ids;
|
||||
if (Array.isArray(overrides)) {
|
||||
lines.push(
|
||||
` 这些来源的目录来自环境变量覆盖,需要确认:${overrides.join("、")}`,
|
||||
" 确认目录无误后加 --confirm-overrides 重新运行。",
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function flowExitCode(document: FlowDocument): number {
|
||||
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function inspectExecutionPlan(value: unknown): {
|
||||
kind: "source_import" | "model_profile_create";
|
||||
operationId: string;
|
||||
digest: string;
|
||||
} | null {
|
||||
const source = validateSourcePlan(value);
|
||||
if (source) {
|
||||
return {
|
||||
kind: source.kind,
|
||||
operationId: source.request_id,
|
||||
digest: source.digest,
|
||||
};
|
||||
}
|
||||
const model = validateModelPlan(value);
|
||||
return model
|
||||
? {
|
||||
kind: model.kind,
|
||||
operationId: model.operation_id,
|
||||
digest: model.digest,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function validateSourcePlan(value: unknown): SourceImportPlan | null {
|
||||
const plan = record(value);
|
||||
const requestId = asString(plan?.request_id);
|
||||
if (
|
||||
plan?.schema_version !== 1 ||
|
||||
plan.kind !== "source_import" ||
|
||||
!UUID.test(requestId) ||
|
||||
!Array.isArray(plan.source_ids) ||
|
||||
plan.source_ids.length < 1 ||
|
||||
plan.source_ids.length > 32 ||
|
||||
plan.source_ids.some(
|
||||
(id) => typeof id !== "string" || !SOURCE_ID.test(id),
|
||||
) ||
|
||||
new Set(plan.source_ids).size !== plan.source_ids.length ||
|
||||
plan.full_rescan !== false ||
|
||||
plan.onboarding !== true ||
|
||||
typeof plan.confirm_environment_overrides !== "boolean" ||
|
||||
typeof plan.digest !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const sourceIds = plan.source_ids as string[];
|
||||
const roots = stringMap(plan.source_roots, sourceIds);
|
||||
const revisions = numberMap(plan.source_candidate_revisions, sourceIds);
|
||||
if (!roots || !revisions) return null;
|
||||
const body: SourceImportPlanBody = {
|
||||
schema_version: 1,
|
||||
kind: "source_import",
|
||||
request_id: requestId,
|
||||
source_ids: [...sourceIds],
|
||||
source_roots: roots,
|
||||
source_candidate_revisions: revisions,
|
||||
confirm_environment_overrides: plan.confirm_environment_overrides,
|
||||
full_rescan: false,
|
||||
onboarding: true,
|
||||
};
|
||||
const expected = withDigest(body);
|
||||
return timingSafeText(expected.digest, plan.digest) ? expected : null;
|
||||
}
|
||||
|
||||
function validateModelPlan(value: unknown): ModelCreatePlan | null {
|
||||
const plan = record(value);
|
||||
const operationId = asString(plan?.operation_id);
|
||||
if (
|
||||
plan?.schema_version !== 1 ||
|
||||
plan.kind !== "model_profile_create" ||
|
||||
!UUID.test(operationId) ||
|
||||
typeof plan.digest !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const config = validateModelConfig(plan.config);
|
||||
if (!config) return null;
|
||||
const expected = withDigest<ModelCreatePlanBody>({
|
||||
schema_version: 1,
|
||||
kind: "model_profile_create",
|
||||
operation_id: operationId,
|
||||
config,
|
||||
});
|
||||
return timingSafeText(expected.digest, plan.digest) ? expected : null;
|
||||
return ["committed", "recovered"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
function validateModelConfig(value: unknown): Record<string, unknown> | null {
|
||||
@@ -644,37 +510,6 @@ function projectJob(job: ImportJob): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function withDigest<T extends object>(body: T): T & { digest: string } {
|
||||
return { ...body, digest: digest(body) };
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return createHash("sha256").update(canonical(value)).digest("hex");
|
||||
}
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map(
|
||||
(key) =>
|
||||
`${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`,
|
||||
)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function preview(plan: SourceImportPlan | ModelCreatePlan): FlowDocument {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "preview",
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
function committed(
|
||||
operationId: string,
|
||||
receipt: Record<string, unknown>,
|
||||
@@ -767,43 +602,3 @@ function optionalInteger(
|
||||
(value as number) <= maximum)
|
||||
);
|
||||
}
|
||||
|
||||
function stringMap(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): Record<string, string> | null {
|
||||
const map = record(value);
|
||||
if (
|
||||
!map ||
|
||||
Object.keys(map).length !== keys.length ||
|
||||
keys.some((key) => !boundedText(map[key], 256))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.fromEntries(keys.map((key) => [key, map[key] as string]));
|
||||
}
|
||||
|
||||
function numberMap(
|
||||
value: unknown,
|
||||
allowedKeys: readonly string[],
|
||||
): Record<string, number> | null {
|
||||
const map = record(value);
|
||||
if (
|
||||
!map ||
|
||||
Object.entries(map).some(
|
||||
([key, revision]) =>
|
||||
!allowedKeys.includes(key) ||
|
||||
!Number.isSafeInteger(revision) ||
|
||||
(revision as number) < 0,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(map).map(([key, revision]) => [key, revision as number]),
|
||||
);
|
||||
}
|
||||
|
||||
function timingSafeText(expected: string, actual: unknown): boolean {
|
||||
return typeof actual === "string" && expected === actual;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { initPlanAttemptPath } from "./cli.js";
|
||||
import { interactiveInitAttemptPath } from "./init-attempt.js";
|
||||
|
||||
// Regression: `init --source <id> --import` used to pass the literal string
|
||||
// `"init"` to `claimPlanAttempt`, which resolved to `<cwd>/init.one-memory-attempt`.
|
||||
// Two consequences: the fence was shared across unrelated runs in the same
|
||||
// directory, and any unrelated file named `init` could make a valid import
|
||||
// report a misleading usage error (exit 2) instead of reading its receipt.
|
||||
describe("init import attempt fence", () => {
|
||||
it("lives under the data dir, not the current working directory", () => {
|
||||
const fence = initPlanAttemptPath(
|
||||
"/tmp/example data",
|
||||
"11111111-2222-4333-8444-555555555555",
|
||||
);
|
||||
expect(fence).toBe(
|
||||
path.join(
|
||||
"/tmp/example data",
|
||||
".cli-init-attempt-11111111-2222-4333-8444-555555555555",
|
||||
),
|
||||
);
|
||||
expect(path.isAbsolute(fence)).toBe(true);
|
||||
expect(fence).not.toBe("init");
|
||||
// Directly under the data dir: no nested directory that could fail to be
|
||||
// created and turn a valid import into a usage error.
|
||||
expect(path.dirname(fence)).toBe("/tmp/example data");
|
||||
});
|
||||
|
||||
it("is per data dir and per operation, so installs/runs cannot collide", () => {
|
||||
const a = initPlanAttemptPath("/tmp/data-a", "op-1");
|
||||
const b = initPlanAttemptPath("/tmp/data-b", "op-1");
|
||||
const c = initPlanAttemptPath("/tmp/data-a", "op-2");
|
||||
expect(new Set([a, b, c]).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// QA-CLI R3 F1: the interactive wizard passed a CWD-relative singleton
|
||||
// (`init` or `<journalDir>/init`) to `claimPlanAttempt`. A stale
|
||||
// `init.one-memory-attempt` in the working directory then made every later
|
||||
// `memhub init` fail with "交互计划无效 / exit 2".
|
||||
describe("interactive init attempt fence", () => {
|
||||
it("lives under the data dir and is unique per operation", () => {
|
||||
const fence = interactiveInitAttemptPath(
|
||||
"/tmp/data",
|
||||
"11111111-2222-4333-8444-555555555555",
|
||||
);
|
||||
expect(fence).toBe(
|
||||
path.join(
|
||||
"/tmp/data",
|
||||
".cli-init-attempt-11111111-2222-4333-8444-555555555555",
|
||||
),
|
||||
);
|
||||
expect(path.isAbsolute(fence)).toBe(true);
|
||||
expect(fence).not.toBe("init");
|
||||
});
|
||||
|
||||
it("does not reuse one fence for two distinct plans", () => {
|
||||
const first = interactiveInitAttemptPath("/tmp/data", "op-1");
|
||||
const second = interactiveInitAttemptPath("/tmp/data", "op-2");
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import path from "node:path";
|
||||
|
||||
/** Where the interactive `init` wizard keeps its durable attempt fence.
|
||||
*
|
||||
* The wizard originally passed a CWD-relative singleton (`init`) to
|
||||
* `claimPlanAttempt`. That shared one fence across unrelated runs and let a
|
||||
* stale `init.one-memory-attempt` in the working directory turn every later
|
||||
* wizard run into "交互计划无效 / exit 2". Keep the fence under the Runtime's
|
||||
* data dir, keyed by the operation id, exactly like `init --source --import`.
|
||||
*/
|
||||
export function interactiveInitAttemptPath(
|
||||
dataDir: string,
|
||||
operationId: string,
|
||||
): string {
|
||||
return path.join(dataDir, `.cli-init-attempt-${operationId}`);
|
||||
}
|
||||
@@ -418,9 +418,9 @@ describe("A2 dual-mode init", () => {
|
||||
});
|
||||
|
||||
// QA-CLI R3 F1: a stale CWD `init.one-memory-attempt` used to poison every
|
||||
// later wizard run. The fence now lives under the isolated data dir and is
|
||||
// keyed by the operation id.
|
||||
it("ignores a stale CWD init fence and keeps its fence under the data dir", async () => {
|
||||
// later wizard run. There are no plan files or attempt fences any more: the
|
||||
// connect request id is the idempotency key Core keeps.
|
||||
it("ignores a stale CWD init fence and writes no fence files", async () => {
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/sources"
|
||||
? { status: 200, data: [candidate("codex", "Codex")] }
|
||||
@@ -471,7 +471,7 @@ describe("A2 dual-mode init", () => {
|
||||
const dataEntries = await readdir(dataDir);
|
||||
expect(
|
||||
dataEntries.some((entry) => entry.startsWith(".cli-init-attempt-")),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps selected Harnesses frozen without creating history jobs", async () => {
|
||||
@@ -506,7 +506,7 @@ describe("A2 dual-mode init", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("A19 init --all-detected", () => {
|
||||
describe("A19 sources connect --all-detected (old spelling: init --all-detected)", () => {
|
||||
it("connects every found source in one batch call without prompting", async () => {
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/sources"
|
||||
@@ -556,7 +556,7 @@ describe("A19 init --all-detected", () => {
|
||||
expect(prompter.asked).toBe(0);
|
||||
expect(code).toBe(0);
|
||||
const document = JSON.parse(streams.value());
|
||||
expect(document.command).toBe("init");
|
||||
expect(document.command).toBe("sources connect");
|
||||
expect(document.state).toBe("committed");
|
||||
expect(
|
||||
document.sources.map((row: { source_id: string }) => row.source_id),
|
||||
|
||||
@@ -4,18 +4,11 @@ import type {
|
||||
CoreDeleteOperations,
|
||||
CoreOperations,
|
||||
} from "./core-operations.js";
|
||||
import {
|
||||
executeModelProfile,
|
||||
type FlowDocument,
|
||||
planModelProfile,
|
||||
} from "./headless-flows.js";
|
||||
import { createModelProfile, type FlowDocument } from "./headless-flows.js";
|
||||
import { approvalFingerprint, listHosts, readHost } from "./host-flow.js";
|
||||
import { parseSelection, type WizardPrompter } from "./init-wizard.js";
|
||||
import { executeModelProbe, planModelProbe } from "./model-probe-flow.js";
|
||||
import {
|
||||
executeModelProfileActivate,
|
||||
planModelProfileActivate,
|
||||
} from "./model-write-flows.js";
|
||||
import { startModelProbe } from "./model-probe-flow.js";
|
||||
import { activateModelProfile } from "./model-write-flows.js";
|
||||
|
||||
type ProviderPreset = {
|
||||
id: string;
|
||||
@@ -359,30 +352,26 @@ export async function runInteractiveModelSetup(
|
||||
);
|
||||
})
|
||||
: new Map<string, string>();
|
||||
const planned = planModelProfile({
|
||||
name: preset.label,
|
||||
provider_kind: preset.provider_kind,
|
||||
base_url: baseUrl,
|
||||
model,
|
||||
embedding_provider_kind: "local_onnx",
|
||||
embedding_model: "bge-small-zh-v1.5",
|
||||
embedding_dimensions: 512,
|
||||
timeout_seconds: 90,
|
||||
max_concurrency: 4,
|
||||
disable_thinking: true,
|
||||
});
|
||||
if (
|
||||
planned.state !== "preview" ||
|
||||
planned.plan.kind !== "model_profile_create"
|
||||
) {
|
||||
return { state: "failed", code: "model_config_invalid" };
|
||||
}
|
||||
const created = await executeModelProfile(
|
||||
planned.plan,
|
||||
const created = await createModelProfile(
|
||||
{
|
||||
name: preset.label,
|
||||
provider_kind: preset.provider_kind,
|
||||
base_url: baseUrl,
|
||||
model,
|
||||
embedding_provider_kind: "local_onnx",
|
||||
embedding_model: "bge-small-zh-v1.5",
|
||||
embedding_dimensions: 512,
|
||||
timeout_seconds: 90,
|
||||
max_concurrency: 4,
|
||||
disable_thinking: true,
|
||||
},
|
||||
apiKey,
|
||||
clients.reader,
|
||||
clients.operations,
|
||||
);
|
||||
if (created.state === "invalid") {
|
||||
return { state: "failed", code: "model_config_invalid" };
|
||||
}
|
||||
const profile = receiptProfile(created);
|
||||
if (profile === null) {
|
||||
prompter.write("模型配置未保存。");
|
||||
@@ -391,18 +380,11 @@ export async function runInteractiveModelSetup(
|
||||
}
|
||||
|
||||
try {
|
||||
const probePlan = await planModelProbe(
|
||||
const probed = await startModelProbe(
|
||||
clients.reader,
|
||||
clients.operations,
|
||||
profile.profile_id,
|
||||
);
|
||||
const probed =
|
||||
probePlan.state === "preview"
|
||||
? await executeModelProbe(
|
||||
probePlan.plan,
|
||||
clients.reader,
|
||||
clients.operations,
|
||||
)
|
||||
: probePlan;
|
||||
if (!probePassed(probed)) {
|
||||
await clients.operations.delete(
|
||||
`/api/v1/model-profiles/${encodeURIComponent(profile.profile_id)}`,
|
||||
@@ -421,17 +403,8 @@ export async function runInteractiveModelSetup(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const activation = planModelProfileActivate({
|
||||
profileId: profile.profile_id,
|
||||
});
|
||||
if (
|
||||
activation.state !== "preview" ||
|
||||
activation.plan.kind !== "model_profile_activate"
|
||||
) {
|
||||
return { state: "failed", code: "model_activate_plan_invalid" };
|
||||
}
|
||||
const activated = await executeModelProfileActivate(
|
||||
activation.plan,
|
||||
const activated = await activateModelProfile(
|
||||
profile.profile_id,
|
||||
clients.operations,
|
||||
);
|
||||
if (activated.state !== "committed") {
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("memhub jobs", () => {
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("start previews without a POST", async () => {
|
||||
it("start posts directly, without a preview step", async () => {
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
@@ -110,14 +110,14 @@ describe("memhub jobs", () => {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
state: "preview",
|
||||
plan: { source_id: "codex", operation: "history" },
|
||||
});
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/import-jobs",
|
||||
{ source_id: "codex", operation: "history" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("start --confirm posts the exact Core request", async () => {
|
||||
it("start still accepts a legacy --confirm", async () => {
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
@@ -146,7 +146,7 @@ describe("memhub jobs", () => {
|
||||
});
|
||||
|
||||
it.each(["pause", "resume", "cancel"] as const)(
|
||||
"%s previews without a POST",
|
||||
"%s posts the action endpoint directly",
|
||||
async (action) => {
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
@@ -156,11 +156,11 @@ describe("memhub jobs", () => {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
state: "preview",
|
||||
plan: { job_id: "job-1", action },
|
||||
});
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
`/api/v1/import-jobs/job-1/${action}`,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
vi.mock("axios", () => ({
|
||||
default: { create: vi.fn(() => transport), get: vi.fn() },
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
const REQUEST = "33333333-3333-4333-8333-333333333333";
|
||||
const EVIDENCE = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
function addReceipt(state = "pending", memoryIds: string[] = []) {
|
||||
return {
|
||||
evidence_id: EVIDENCE,
|
||||
text: "Prefers tea",
|
||||
scope: "all-apps",
|
||||
created_at: "2026-10-27T00:00:00Z",
|
||||
state,
|
||||
task_id: null,
|
||||
memory_ids: memoryIds,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function memoryRecord(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
memory_id: "exp-1",
|
||||
content: "deploy\nrun make",
|
||||
layer: "experience",
|
||||
status: "active",
|
||||
is_latest: true,
|
||||
supersedes: [],
|
||||
superseded_by: [],
|
||||
tags: [],
|
||||
owner: null,
|
||||
source_raw_ids: [],
|
||||
user_id: "local",
|
||||
agent_id: "",
|
||||
session_id: null,
|
||||
source: "codebuddy",
|
||||
memory_at: null,
|
||||
gmt_created: 1_790_000_000,
|
||||
scene: "working",
|
||||
kind: "experience",
|
||||
variant: "working",
|
||||
task: "deploy",
|
||||
solution: "run make",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
transport.get.mockReset();
|
||||
transport.post.mockReset();
|
||||
transport.put.mockReset();
|
||||
transport.patch.mockReset();
|
||||
transport.delete.mockReset();
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
instanceId: "instance-1",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
version: "1.2.3",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue({
|
||||
instanceId: "instance-1",
|
||||
apiToken: "synthetic-runtime-token",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
servicePid: 4242,
|
||||
health: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.2.3",
|
||||
instance_id: "instance-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("memories add", () => {
|
||||
it("adds one memory directly and prints the receipt with its request id", async () => {
|
||||
transport.post.mockResolvedValue({ status: 201, data: addReceipt() });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"add",
|
||||
"Prefers tea",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
const [url, body] = transport.post.mock.calls[0];
|
||||
expect(url).toBe("/api/v2/memories");
|
||||
expect(body).toMatchObject({ text: "Prefers tea", scope: "all-apps" });
|
||||
expect(body.request_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
schema_version: 2,
|
||||
command: "memories add",
|
||||
state: "accepted",
|
||||
request_id: body.request_id,
|
||||
receipt: { evidence_id: EVIDENCE, state: "pending" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses --request-id so a retry is idempotent, and says so in text", async () => {
|
||||
transport.post.mockResolvedValue({ status: 201, data: addReceipt() });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"add",
|
||||
"Prefers tea",
|
||||
"--request-id",
|
||||
REQUEST,
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post.mock.calls[0][1].request_id).toBe(REQUEST);
|
||||
expect(result.stdout).toContain(`request_id: ${REQUEST}`);
|
||||
expect(result.stdout).toContain(`memhub memories add-status ${REQUEST}`);
|
||||
});
|
||||
|
||||
it("reads the text from a bounded file", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-memory-add-"));
|
||||
const file = path.join(directory, "memory.txt");
|
||||
await writeFile(file, "Prefers tea\n", { mode: 0o600 });
|
||||
transport.post.mockResolvedValue({ status: 201, data: addReceipt() });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"add",
|
||||
"--content-file",
|
||||
file,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post.mock.calls[0][1].text).toBe("Prefers tea");
|
||||
});
|
||||
|
||||
it("refuses bad input before contacting Core", async () => {
|
||||
for (const argv of [
|
||||
["memories", "add"],
|
||||
["memories", "add", "a", "--content-file", "/tmp/x"],
|
||||
["memories", "add", "a", "--request-id", "not-a-uuid"],
|
||||
["memories", "add", " "],
|
||||
["memories", "add", "x".repeat(12_001)],
|
||||
]) {
|
||||
expect((await capturedRun(argv)).code).toBe(2);
|
||||
}
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a Core conflict as rejected, on stdout, exit 1", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "memory_add_conflict" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"add",
|
||||
"Other text",
|
||||
"--request-id",
|
||||
REQUEST,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
state: "rejected",
|
||||
code: "memory_add_conflict",
|
||||
request_id: REQUEST,
|
||||
});
|
||||
});
|
||||
|
||||
it("never reports a malformed success body as accepted", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 201,
|
||||
data: { evidence_id: "x" },
|
||||
});
|
||||
const result = await capturedRun(["memories", "add", "tea", "--json"]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(JSON.parse(result.stdout).state).toBe("unknown");
|
||||
});
|
||||
|
||||
it("answers a stopped Runtime with exit 3 and writes nothing", async () => {
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "stopped",
|
||||
code: "runtime_not_running",
|
||||
});
|
||||
const result = await capturedRun(["memories", "add", "tea", "--json"]);
|
||||
expect(result.code).toBe(3);
|
||||
expect(JSON.parse(result.stdout).code).toBe("runtime_not_running");
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("memories add-status", () => {
|
||||
it("reads the addition by request id", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: addReceipt("ready", ["m-1"]),
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"add-status",
|
||||
REQUEST,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.get).toHaveBeenCalledWith(
|
||||
`/api/v2/memories/additions/${REQUEST}`,
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "memories add-status",
|
||||
state: "accepted",
|
||||
receipt: { state: "ready", memory_ids: ["m-1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unknown request id as not_found, exit 1", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 404,
|
||||
data: { detail: "memory_add_not_found" },
|
||||
});
|
||||
const result = await capturedRun(["memories", "status", REQUEST]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).toContain("未找到该 request id");
|
||||
});
|
||||
|
||||
it("refuses a non-UUID request id", async () => {
|
||||
expect((await capturedRun(["memories", "add-status", "abc"])).code).toBe(2);
|
||||
expect(transport.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("memories update / delete pass the namespace", () => {
|
||||
it("updates an experience with kind, variant and task, directly", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-memory-update-"));
|
||||
const file = path.join(directory, "solution.txt");
|
||||
await writeFile(file, "run make deploy\n", { mode: 0o600 });
|
||||
transport.put.mockResolvedValue({ status: 200, data: memoryRecord() });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"update",
|
||||
"exp-1",
|
||||
"--content-file",
|
||||
file,
|
||||
"--kind",
|
||||
"experience",
|
||||
"--variant",
|
||||
"working",
|
||||
"--task",
|
||||
"deploy",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.put).toHaveBeenCalledWith(
|
||||
"/api/v2/memories/exp-1",
|
||||
{
|
||||
content: "run make deploy",
|
||||
kind: "experience",
|
||||
variant: "working",
|
||||
task: "deploy",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
schema_version: 2,
|
||||
state: "committed",
|
||||
operation: "update",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses --task on anything but an experience, and a lone --variant", async () => {
|
||||
for (const argv of [
|
||||
["memories", "update", "m-1", "--content-file", "/tmp/x", "--task", "t"],
|
||||
[
|
||||
"memories",
|
||||
"update",
|
||||
"m-1",
|
||||
"--content-file",
|
||||
"/tmp/x",
|
||||
"--kind",
|
||||
"fact",
|
||||
"--variant",
|
||||
"coding",
|
||||
"--task",
|
||||
"t",
|
||||
],
|
||||
["memories", "delete", "m-1", "--variant", "coding"],
|
||||
["memories", "delete", "m-1", "--kind", "chat", "--variant", "coding"],
|
||||
["memories", "show", "m-1", "--kind", "nope"],
|
||||
]) {
|
||||
expect((await capturedRun(argv)).code).toBe(2);
|
||||
}
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
expect(transport.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews a delete without --yes and changes nothing", async () => {
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"delete",
|
||||
"fact-1",
|
||||
"--kind",
|
||||
"fact",
|
||||
"--variant",
|
||||
"coding",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
schema_version: 2,
|
||||
command: "memories delete",
|
||||
state: "preview",
|
||||
target: "fact-1",
|
||||
applied: false,
|
||||
});
|
||||
expect(JSON.parse(result.stdout).next_step).toContain(
|
||||
"--kind fact --variant coding --yes",
|
||||
);
|
||||
expect(transport.delete).not.toHaveBeenCalled();
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes with --yes (or the legacy --confirm) and names the namespace", async () => {
|
||||
transport.delete.mockResolvedValue({ status: 204, data: "" });
|
||||
for (const flag of ["--yes", "--confirm"]) {
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"delete",
|
||||
"fact-1",
|
||||
"--kind",
|
||||
"fact",
|
||||
"--variant",
|
||||
"coding",
|
||||
flag,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
schema_version: 2,
|
||||
state: "committed",
|
||||
operation: "delete",
|
||||
});
|
||||
}
|
||||
expect(transport.delete).toHaveBeenCalledWith(
|
||||
"/api/v2/memories/fact-1?kind=fact&variant=coding",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("explains an ambiguous id instead of a bare code", async () => {
|
||||
transport.delete.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "memory_id_ambiguous" },
|
||||
});
|
||||
const result = await capturedRun(["memories", "delete", "dup", "--yes"]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).toContain("--kind");
|
||||
});
|
||||
|
||||
it("shows a memory in its namespace", async () => {
|
||||
transport.get.mockResolvedValue({ status: 200, data: memoryRecord() });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"show",
|
||||
"exp-1",
|
||||
"--kind",
|
||||
"experience",
|
||||
"--variant",
|
||||
"working",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.get).toHaveBeenCalledWith("/api/v2/memories/exp-1", {
|
||||
params: { kind: "experience", variant: "working" },
|
||||
});
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.schema_version).toBe(2);
|
||||
expect(document).not.toHaveProperty("evidence");
|
||||
expect(document).not.toHaveProperty("history");
|
||||
expect(document.memory).toMatchObject({
|
||||
kind: "experience",
|
||||
variant: "working",
|
||||
task: "deploy",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("memories list filters", () => {
|
||||
const page = {
|
||||
memories: [memoryRecord()],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
search_window: 200,
|
||||
window_id: "w1",
|
||||
window_reset: false,
|
||||
};
|
||||
|
||||
it("sends --scene, --source and --query to the library", async () => {
|
||||
transport.get.mockResolvedValue({ status: 200, data: page });
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"list",
|
||||
"--scene",
|
||||
"working",
|
||||
"--source",
|
||||
"codebuddy",
|
||||
"--query",
|
||||
"deploy",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.get).toHaveBeenCalledWith("/api/v2/memory-library", {
|
||||
params: {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
scene: "working",
|
||||
source: "codebuddy",
|
||||
query: "deploy",
|
||||
},
|
||||
});
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.schema_version).toBe(2);
|
||||
expect(document.page).toMatchObject({
|
||||
state: "ok",
|
||||
scene: "working",
|
||||
query: "deploy",
|
||||
search_window: 200,
|
||||
});
|
||||
expect(document.memories[0]).toMatchObject({
|
||||
kind: "experience",
|
||||
variant: "working",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits scene for the default all", async () => {
|
||||
transport.get.mockResolvedValue({ status: 200, data: page });
|
||||
await capturedRun(["memories", "list", "--scene", "all", "--json"]);
|
||||
expect(transport.get.mock.calls[0][1].params).not.toHaveProperty("scene");
|
||||
});
|
||||
|
||||
it("validates --status, --scene and --query locally instead of reporting a busy backend", async () => {
|
||||
for (const argv of [
|
||||
["memories", "list", "--status", "bogus"],
|
||||
["memories", "list", "--scene", "normal"],
|
||||
["memories", "list", "--query", "x", "--status", "archived"],
|
||||
["memories", "list", "--source", "Bad Source"],
|
||||
]) {
|
||||
expect((await capturedRun(argv)).code).toBe(2);
|
||||
}
|
||||
expect(transport.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a Core 422 as rejected, not as 后台正忙", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 422,
|
||||
data: { detail: "sdk_search_supports_active_status_only" },
|
||||
});
|
||||
const result = await capturedRun(["memories", "list"]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).not.toContain("后台正忙");
|
||||
expect(result.stdout).toContain("sdk_search_supports_active_status_only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("graph-width", () => {
|
||||
it("runs a non-destructive decision directly", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { archived_path: null, exported_path: "/tmp/x", mismatch: null },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"graph-width",
|
||||
"--decision",
|
||||
"export_first",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/graph-width",
|
||||
{ decision: "export_first" },
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(result.stdout).state).toBe("committed");
|
||||
});
|
||||
|
||||
it("previews rebuild_with_new without --yes and applies it with --yes", async () => {
|
||||
const preview = await capturedRun([
|
||||
"graph-width",
|
||||
"--decision",
|
||||
"rebuild_with_new",
|
||||
"--json",
|
||||
]);
|
||||
expect(preview.code).toBe(0);
|
||||
expect(JSON.parse(preview.stdout)).toMatchObject({
|
||||
state: "preview",
|
||||
applied: false,
|
||||
});
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { archived_path: "/tmp/old", exported_path: null, mismatch: null },
|
||||
});
|
||||
const applied = await capturedRun([
|
||||
"graph",
|
||||
"width",
|
||||
"--decision",
|
||||
"rebuild_with_new",
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
expect(applied.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/graph-width",
|
||||
{ decision: "rebuild_with_new" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removed commands", () => {
|
||||
it.each([
|
||||
["notes", "add", "--content-file", "/tmp/x"],
|
||||
["notes", "process", REQUEST, "--confirm"],
|
||||
["calls", "clear", "--all", "--confirm"],
|
||||
["processing", "extract", "--evidence", REQUEST, "--confirm"],
|
||||
["extraction", "set", "--window", "6m", "--confirm"],
|
||||
["import-file", "upload", "--file", "/tmp/x", "--confirm"],
|
||||
])("%s is an unknown command that writes nothing", async (...argv) => {
|
||||
const result = await capturedRun(argv as string[]);
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stderr).toContain("未知命令");
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
async function capturedRun(arguments_: string[]) {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
return {
|
||||
code: await run(arguments_, {
|
||||
ONE_MEMORY_DATA_DIR: "/synthetic/data",
|
||||
}),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { SdkMemoryPage, SdkMemoryRecord } from "@one-memory/contracts";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
MemoryAddReceipt,
|
||||
SdkMemoryPage,
|
||||
SdkMemoryRecord,
|
||||
} from "@one-memory/contracts";
|
||||
import {
|
||||
buildMemoryAddRequest,
|
||||
MemoryAddInputError,
|
||||
memoryAddStatusPath,
|
||||
parseMemoryAddReceipt,
|
||||
} from "@one-memory/operations";
|
||||
import { type CoreReader, RESERVED_MEMORY_SEGMENTS } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
/** Output budget: a long memory is shortened visibly, never dropped silently. */
|
||||
@@ -38,27 +50,52 @@ export function parseMemoryContent(bytes: Buffer): string | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
export const MEMORY_SCENES = ["all", "chat", "working", "coding"] as const;
|
||||
export type MemoryScene = (typeof MEMORY_SCENES)[number];
|
||||
export const MEMORY_KINDS = ["chat", "experience", "fact"] as const;
|
||||
export type MemoryKind = (typeof MEMORY_KINDS)[number];
|
||||
export const MEMORY_VARIANTS = ["working", "coding"] as const;
|
||||
export type MemoryVariant = (typeof MEMORY_VARIANTS)[number];
|
||||
|
||||
/** Which SDK namespace a memory lives in. A chat memory needs neither; a
|
||||
* working/coding experience or fact needs both so Core does not have to guess
|
||||
* (an id present in two namespaces is refused as ambiguous). */
|
||||
export interface MemoryNamespace {
|
||||
kind?: MemoryKind;
|
||||
variant?: MemoryVariant;
|
||||
}
|
||||
|
||||
export function namespaceParameters(
|
||||
namespace: MemoryNamespace,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...(namespace.kind === undefined ? {} : { kind: namespace.kind }),
|
||||
...(namespace.variant === undefined ? {} : { variant: namespace.variant }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MemoryQuery {
|
||||
query?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
scene?: MemoryScene;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** Every `memories` document is schema 2: list/show share it with
|
||||
* add/update/delete, and the v1-era `evidence`/`history` fields are gone. */
|
||||
export interface MemoryPageDocument {
|
||||
schema_version: 1;
|
||||
schema_version: 2;
|
||||
product: string;
|
||||
page: Record<string, unknown>;
|
||||
memories: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface MemoryDetailDocument {
|
||||
schema_version: 1;
|
||||
schema_version: 2;
|
||||
product: string;
|
||||
memory: Record<string, unknown>;
|
||||
evidence: Record<string, unknown>[];
|
||||
history: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export async function readMemoryPage(
|
||||
@@ -70,10 +107,15 @@ export async function readMemoryPage(
|
||||
limit: query.limit,
|
||||
};
|
||||
if (query.status !== undefined) parameters.statuses = query.status;
|
||||
if (query.source !== undefined) parameters.source = query.source;
|
||||
if (query.query !== undefined) parameters.query = query.query;
|
||||
if (query.scene !== undefined && query.scene !== "all") {
|
||||
parameters.scene = query.scene;
|
||||
}
|
||||
const outcome = await reader<unknown>("/api/v2/memory-library", parameters);
|
||||
if (outcome.state !== "ok") {
|
||||
return {
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
page: { state: outcome.state, code: outcome.code },
|
||||
memories: [],
|
||||
@@ -82,7 +124,7 @@ export async function readMemoryPage(
|
||||
const page = sdkMemoryPage(outcome.data);
|
||||
if (page === null) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
page: { state: "invalid", code: "response_invalid" },
|
||||
memories: [],
|
||||
@@ -90,15 +132,21 @@ export async function readMemoryPage(
|
||||
}
|
||||
const items = page.memories;
|
||||
return {
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
page: {
|
||||
state: "ok",
|
||||
scene: query.scene ?? "all",
|
||||
...(query.query === undefined ? {} : { query: query.query }),
|
||||
total: page.total,
|
||||
offset: page.offset,
|
||||
limit: page.limit,
|
||||
returned: items.length,
|
||||
has_more: page.offset + items.length < page.total,
|
||||
// A keyword search only looks among this many most relevant memories.
|
||||
...(typeof page.search_window === "number"
|
||||
? { search_window: page.search_window }
|
||||
: {}),
|
||||
},
|
||||
memories: items.map((item) => describeMemory(item)),
|
||||
};
|
||||
@@ -107,42 +155,195 @@ export async function readMemoryPage(
|
||||
export async function readMemoryDetail(
|
||||
reader: CoreReader,
|
||||
memoryId: string,
|
||||
namespace: MemoryNamespace = {},
|
||||
): Promise<MemoryDetailDocument> {
|
||||
const outcome = await reader<unknown>(`/api/v2/memories/${memoryId}`);
|
||||
const parameters = namespaceParameters(namespace);
|
||||
const outcome = await reader<unknown>(
|
||||
`/api/v2/memories/${memoryId}`,
|
||||
Object.keys(parameters).length > 0 ? parameters : undefined,
|
||||
);
|
||||
if (outcome.state !== "ok") {
|
||||
return {
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
memory: { state: outcome.state, code: outcome.code },
|
||||
evidence: [],
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
const memory = sdkMemory(outcome.data);
|
||||
if (memory === null || memory.memory_id !== memoryId) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
memory: { state: "invalid", code: "response_invalid" },
|
||||
evidence: [],
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
memory: { state: "ok", ...describeMemory(memory) },
|
||||
evidence: [],
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
/** The same bound the app's 添加记忆 box applies. */
|
||||
export const MEMORY_ADD_MAX_CHARACTERS = 12_000;
|
||||
|
||||
export interface MemoryAddDocument {
|
||||
schema_version: 2;
|
||||
product: string;
|
||||
command: "memories add" | "memories add-status";
|
||||
state: "accepted" | "rejected" | "unknown" | "not_found" | "invalid";
|
||||
request_id: string;
|
||||
code?: string;
|
||||
receipt?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function isRequestId(value: string): boolean {
|
||||
return UUID.test(value);
|
||||
}
|
||||
|
||||
/** A memory text: non-blank, bounded, no control characters except tab and
|
||||
* newline. */
|
||||
export function validMemoryText(text: string): boolean {
|
||||
return (
|
||||
text.trim().length > 0 &&
|
||||
[...text].length <= MEMORY_ADD_MAX_CHARACTERS &&
|
||||
![...text].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 8 || (code >= 11 && code <= 31) || code === 127;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function addReceipt(receipt: MemoryAddReceipt): Record<string, unknown> {
|
||||
return {
|
||||
evidence_id: receipt.evidence_id,
|
||||
state: receipt.state,
|
||||
created_at: receipt.created_at,
|
||||
task_id: receipt.task_id,
|
||||
memory_ids: receipt.memory_ids,
|
||||
error: receipt.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one memory directly, the same request as the app's 添加记忆.
|
||||
*
|
||||
* `requestId` is Core's idempotency key: retrying with the same id and text
|
||||
* returns the first receipt; the same id with other text is refused.
|
||||
*/
|
||||
export async function addMemory(
|
||||
operations: CoreOperations,
|
||||
text: string,
|
||||
requestId: string = randomUUID(),
|
||||
): Promise<MemoryAddDocument> {
|
||||
const base = {
|
||||
schema_version: 2 as const,
|
||||
product: PRODUCT,
|
||||
command: "memories add" as const,
|
||||
request_id: requestId,
|
||||
};
|
||||
if (!validMemoryText(text) || !UUID.test(requestId)) {
|
||||
return { ...base, state: "invalid", code: "memory_add_invalid" };
|
||||
}
|
||||
let request: ReturnType<typeof buildMemoryAddRequest>;
|
||||
try {
|
||||
request = buildMemoryAddRequest({
|
||||
request_id: requestId,
|
||||
text,
|
||||
scope: "all-apps",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof MemoryAddInputError) {
|
||||
return { ...base, state: "invalid", code: "memory_add_invalid" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await operations.post<unknown>(request.path, request.body);
|
||||
if (result.state !== "committed") {
|
||||
return { ...base, state: result.state, code: result.code };
|
||||
}
|
||||
const receipt = parseMemoryAddReceipt(result.data);
|
||||
return receipt === null
|
||||
? { ...base, state: "unknown", code: "memory_add_receipt_invalid" }
|
||||
: { ...base, state: "accepted", receipt: addReceipt(receipt) };
|
||||
}
|
||||
|
||||
/** Read how one addition is doing, by the request id it was submitted with. */
|
||||
export async function readMemoryAddStatus(
|
||||
reader: CoreReader,
|
||||
requestId: string,
|
||||
): Promise<MemoryAddDocument> {
|
||||
const base = {
|
||||
schema_version: 2 as const,
|
||||
product: PRODUCT,
|
||||
command: "memories add-status" as const,
|
||||
request_id: requestId,
|
||||
};
|
||||
if (!UUID.test(requestId)) {
|
||||
return { ...base, state: "invalid", code: "invalid_request_id" };
|
||||
}
|
||||
const outcome = await reader<unknown>(memoryAddStatusPath(requestId));
|
||||
if (outcome.state === "not_found") {
|
||||
return { ...base, state: "not_found", code: "memory_add_not_found" };
|
||||
}
|
||||
if (outcome.state !== "ok") {
|
||||
return {
|
||||
...base,
|
||||
state: outcome.state === "rejected" ? "rejected" : "unknown",
|
||||
code: outcome.code,
|
||||
};
|
||||
}
|
||||
const receipt = parseMemoryAddReceipt(outcome.data);
|
||||
return receipt === null
|
||||
? { ...base, state: "unknown", code: "memory_add_receipt_invalid" }
|
||||
: { ...base, state: "accepted", receipt: addReceipt(receipt) };
|
||||
}
|
||||
|
||||
export function memoryAddExitCode(document: MemoryAddDocument): number {
|
||||
return document.state === "accepted" ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderMemoryAddText(document: MemoryAddDocument): string {
|
||||
if (document.state === "accepted" && document.receipt) {
|
||||
return [
|
||||
`request_id: ${document.request_id}`,
|
||||
`evidence_id: ${document.receipt.evidence_id}`,
|
||||
`state: ${document.receipt.state}`,
|
||||
...((document.receipt.memory_ids as string[]).length > 0
|
||||
? [`memory_ids: ${(document.receipt.memory_ids as string[]).join(",")}`]
|
||||
: []),
|
||||
document.command === "memories add"
|
||||
? `查询进度:memhub memories add-status ${document.request_id};重试请带上同一个 --request-id,不会重复添加。`
|
||||
: "",
|
||||
"",
|
||||
]
|
||||
.filter((line, index, all) => line !== "" || index === all.length - 1)
|
||||
.join("\n");
|
||||
}
|
||||
if (document.state === "not_found") {
|
||||
return `未找到该 request id:${document.request_id}\n`;
|
||||
}
|
||||
return [
|
||||
`添加记忆未完成:${document.state}(${document.code})`,
|
||||
document.state === "unknown"
|
||||
? `结果未知;可以用同一个 --request-id ${document.request_id} 安全重试。`
|
||||
: "",
|
||||
"",
|
||||
]
|
||||
.filter((line, index, all) => line !== "" || index === all.length - 1)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function renderMemoryPageText(document: MemoryPageDocument): string {
|
||||
const page = document.page;
|
||||
if (page.state !== "ok") {
|
||||
if (page.state === "unavailable") {
|
||||
return "后台正忙,记忆列表这次没查完,不代表没有记忆,请稍后再试\n";
|
||||
}
|
||||
if (page.state === "rejected") {
|
||||
return `记忆查询被拒绝(${page.code}),请检查参数。\n`;
|
||||
}
|
||||
return `记忆查询未完成:${page.state}(${page.code})\n`;
|
||||
}
|
||||
const lines = [
|
||||
@@ -150,9 +351,16 @@ export function renderMemoryPageText(document: MemoryPageDocument): string {
|
||||
page.has_more ? ",还有更多,请用 --offset 继续" : ""
|
||||
}`,
|
||||
];
|
||||
if (typeof page.search_window === "number") {
|
||||
lines.push(`(搜索只在最相关的 ${page.search_window} 条里匹配关键词)`);
|
||||
}
|
||||
for (const memory of document.memories) {
|
||||
const namespace =
|
||||
memory.kind === "chat"
|
||||
? ""
|
||||
: ` kind=${memory.kind} variant=${memory.variant}`;
|
||||
lines.push(
|
||||
`- ${memory.memory_id} [${memory.scene}] ${memory.status}/${memory.layer} source=${memory.source ?? "未知"}`,
|
||||
`- ${memory.memory_id} [${memory.scene}] ${memory.status}/${memory.layer} source=${memory.source ?? "未知"}${namespace}`,
|
||||
` ${memory.content}${memory.content_truncated ? ` …(共 ${memory.content_characters} 字)` : ""}`,
|
||||
);
|
||||
}
|
||||
@@ -167,6 +375,9 @@ export function renderMemoryDetailText(document: MemoryDetailDocument): string {
|
||||
if (memory.state === "unavailable") {
|
||||
return "后台正忙,记忆详情这次没查完,不代表没有这条记忆,请稍后再试\n";
|
||||
}
|
||||
if (memory.code === "memory_id_ambiguous") {
|
||||
return "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n";
|
||||
}
|
||||
return `记忆读取未完成:${memory.state}(${memory.code})\n`;
|
||||
}
|
||||
const lines = [
|
||||
@@ -204,6 +415,9 @@ function describeMemory(memory: SdkMemoryRecord): Record<string, unknown> {
|
||||
tags: memory.tags ?? [],
|
||||
memory_at: memory.memory_at,
|
||||
gmt_created: memory.gmt_created,
|
||||
...(memory.task === undefined || memory.task === null
|
||||
? {}
|
||||
: { task: memory.task }),
|
||||
content: budgeted.text,
|
||||
content_truncated: budgeted.truncated,
|
||||
content_characters: budgeted.characters,
|
||||
@@ -221,6 +435,9 @@ function sdkMemoryPage(value: unknown): SdkMemoryPage | null {
|
||||
total: value.total,
|
||||
limit: value.limit,
|
||||
offset: value.offset,
|
||||
...(integer(value.search_window)
|
||||
? { search_window: value.search_window }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
executeModelProbe,
|
||||
planModelProbe,
|
||||
modelProbeExitCode,
|
||||
readModelProbeReceipt,
|
||||
startModelProbe,
|
||||
} from "./model-probe-flow.js";
|
||||
|
||||
const PROFILE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
@@ -83,67 +83,101 @@ function completed(operationId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("model probe operation flow", () => {
|
||||
it("plans against the exact current profile revision", async () => {
|
||||
const reader = vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] });
|
||||
const document = await planModelProbe(reader as CoreReader, PROFILE_ID);
|
||||
expect(document.state).toBe("preview");
|
||||
if (document.state !== "preview") throw new Error("expected preview");
|
||||
expect(document.plan).toMatchObject({
|
||||
kind: "model_profile_probe",
|
||||
profile_id: PROFILE_ID,
|
||||
expected_profile_revision: 3,
|
||||
});
|
||||
expect(document.plan.operation_id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(document.plan.digest).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
const OPERATION_ID = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
it("proves operation support with schema-1 not_found before posting", async () => {
|
||||
const planned = await planModelProbe(
|
||||
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
|
||||
PROFILE_ID,
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const operationId = planned.plan.operation_id;
|
||||
const reader = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ state: "ok", data: notFound(operationId) });
|
||||
/** Reader that answers the profile list, then the queued operation reads. */
|
||||
function readerWith(...operationReads: unknown[]) {
|
||||
const reader = vi.fn(async (path: string) => {
|
||||
if (path === "/api/v1/model-profiles") {
|
||||
return { state: "ok", data: [PROFILE] };
|
||||
}
|
||||
return operationReads.shift() ?? { state: "not_found", code: "http_404" };
|
||||
});
|
||||
return reader;
|
||||
}
|
||||
|
||||
describe("model probe operation flow", () => {
|
||||
it("probes the exact current profile revision once support is proven", async () => {
|
||||
const reader = readerWith({ state: "ok", data: notFound(OPERATION_ID) });
|
||||
const post = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ state: "committed", data: completed(operationId) });
|
||||
.mockResolvedValue({ state: "committed", data: completed(OPERATION_ID) });
|
||||
|
||||
const result = await executeModelProbe(
|
||||
planned.plan,
|
||||
reader as CoreReader,
|
||||
const result = await startModelProbe(
|
||||
reader as unknown as CoreReader,
|
||||
{ post } as CoreOperations,
|
||||
PROFILE_ID,
|
||||
OPERATION_ID,
|
||||
);
|
||||
|
||||
expect(result.state).toBe("completed");
|
||||
expect(reader).toHaveBeenCalledWith(
|
||||
`/api/v1/model-probe-operations/${operationId}`,
|
||||
`/api/v1/model-probe-operations/${OPERATION_ID}`,
|
||||
);
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
`/api/v1/model-profiles/${PROFILE_ID}/probe`,
|
||||
{ operation_id: operationId, expected_profile_revision: 3 },
|
||||
{ operation_id: OPERATION_ID, expected_profile_revision: 3 },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not post when the Core lacks the exact operation contract", async () => {
|
||||
const planned = await planModelProbe(
|
||||
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
|
||||
it("generates its own operation id and treats a pending probe as submitted", async () => {
|
||||
const post = vi.fn(
|
||||
async (_path: string, body: Record<string, unknown>) => ({
|
||||
state: "committed",
|
||||
data: {
|
||||
...notFound(String(body.operation_id)),
|
||||
profile_id: PROFILE_ID,
|
||||
expected_profile_revision: 3,
|
||||
state: "pending",
|
||||
stale: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const reader = vi.fn(async (path: string) =>
|
||||
path === "/api/v1/model-profiles"
|
||||
? { state: "ok", data: [PROFILE] }
|
||||
: {
|
||||
state: "ok",
|
||||
data: notFound(path.split("/").at(-1) ?? ""),
|
||||
},
|
||||
);
|
||||
const result = await startModelProbe(
|
||||
reader as unknown as CoreReader,
|
||||
{ post } as unknown as CoreOperations,
|
||||
PROFILE_ID,
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
expect(result.state).toBe("pending");
|
||||
expect(result.operation_id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(modelProbeExitCode(result)).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses an unknown profile without posting", async () => {
|
||||
const post = vi.fn();
|
||||
const result = await executeModelProbe(
|
||||
planned.plan,
|
||||
vi.fn().mockResolvedValue({
|
||||
const result = await startModelProbe(
|
||||
readerWith() as unknown as CoreReader,
|
||||
{ post } as CoreOperations,
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
OPERATION_ID,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "not_found",
|
||||
code: "model_profile_not_found",
|
||||
});
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not post when the Core lacks the exact operation contract", async () => {
|
||||
const post = vi.fn();
|
||||
const result = await startModelProbe(
|
||||
readerWith({
|
||||
state: "not_found",
|
||||
code: "http_404",
|
||||
}) as CoreReader,
|
||||
}) as unknown as CoreReader,
|
||||
{ post } as CoreOperations,
|
||||
PROFILE_ID,
|
||||
OPERATION_ID,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "disabled",
|
||||
@@ -153,19 +187,15 @@ describe("model probe operation flow", () => {
|
||||
});
|
||||
|
||||
it("does not post when a legacy Core returns a non-operation body", async () => {
|
||||
const planned = await planModelProbe(
|
||||
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
|
||||
PROFILE_ID,
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const post = vi.fn();
|
||||
const result = await executeModelProbe(
|
||||
planned.plan,
|
||||
vi.fn().mockResolvedValue({
|
||||
const result = await startModelProbe(
|
||||
readerWith({
|
||||
state: "ok",
|
||||
data: { detail: "legacy_route_shape" },
|
||||
}) as CoreReader,
|
||||
}) as unknown as CoreReader,
|
||||
{ post } as CoreOperations,
|
||||
PROFILE_ID,
|
||||
OPERATION_ID,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "disabled",
|
||||
@@ -175,24 +205,19 @@ describe("model probe operation flow", () => {
|
||||
});
|
||||
|
||||
it("reconciles an unknown POST by GET and never treats not_found as replay permission", async () => {
|
||||
const planned = await planModelProbe(
|
||||
vi.fn().mockResolvedValue({ state: "ok", data: [PROFILE] }) as CoreReader,
|
||||
PROFILE_ID,
|
||||
const reader = readerWith(
|
||||
{ state: "ok", data: notFound(OPERATION_ID) },
|
||||
{ state: "ok", data: notFound(OPERATION_ID) },
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const operationId = planned.plan.operation_id;
|
||||
const reader = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ state: "ok", data: notFound(operationId) })
|
||||
.mockResolvedValueOnce({ state: "ok", data: notFound(operationId) });
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
state: "unknown",
|
||||
code: "transport_result_unknown",
|
||||
});
|
||||
const result = await executeModelProbe(
|
||||
planned.plan,
|
||||
reader as CoreReader,
|
||||
const result = await startModelProbe(
|
||||
reader as unknown as CoreReader,
|
||||
{ post } as CoreOperations,
|
||||
PROFILE_ID,
|
||||
OPERATION_ID,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "unknown",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { validateModelProfiles } from "./core-responses.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const RECEIPT_KEYS = new Set([
|
||||
"schema_version",
|
||||
"operation_id",
|
||||
@@ -59,23 +58,7 @@ const CODES = new Set([
|
||||
"embedding_unavailable",
|
||||
]);
|
||||
|
||||
interface ModelProbePlanBody {
|
||||
schema_version: 1;
|
||||
kind: "model_profile_probe";
|
||||
operation_id: string;
|
||||
profile_id: string;
|
||||
expected_profile_revision: number;
|
||||
}
|
||||
|
||||
export type ModelProbePlan = ModelProbePlanBody & { digest: string };
|
||||
|
||||
export type ModelProbeFlowDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "preview";
|
||||
plan: ModelProbePlan;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
@@ -91,11 +74,25 @@ export type ModelProbeFlowDocument =
|
||||
code: string;
|
||||
};
|
||||
|
||||
export async function planModelProbe(
|
||||
/**
|
||||
* Start one capability probe of a model profile (one real model request).
|
||||
*
|
||||
* Reads the profile's current revision so Core can refuse a probe of a
|
||||
* profile that changed meanwhile, checks that this Core tracks probe
|
||||
* operations (an old Core would start an untracked request), then submits it.
|
||||
* The probe runs asynchronously: the answer is usually `pending`, and
|
||||
* `models receipt <operation-id>` reads the result.
|
||||
*/
|
||||
export async function startModelProbe(
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
profileId: string,
|
||||
operationId: string = randomUUID(),
|
||||
): Promise<ModelProbeFlowDocument> {
|
||||
if (!UUID.test(profileId)) return failure("invalid", "invalid_profile_id");
|
||||
if (!UUID.test(operationId)) {
|
||||
return failure("invalid", "invalid_probe_operation_id");
|
||||
}
|
||||
const outcome = await reader<unknown>("/api/v1/model-profiles");
|
||||
if (outcome.state !== "ok") return fromReadFailure(outcome);
|
||||
const profiles = validateModelProfiles(outcome.data);
|
||||
@@ -105,71 +102,46 @@ export async function planModelProbe(
|
||||
);
|
||||
if (matches.length !== 1)
|
||||
return failure("not_found", "model_profile_not_found");
|
||||
const profile = matches[0];
|
||||
if (!Number.isSafeInteger(profile.revision) || profile.revision < 1) {
|
||||
const revision = matches[0].revision;
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||
return failure("invalid", "model_profile_revision_invalid");
|
||||
}
|
||||
const plan = withDigest({
|
||||
schema_version: 1 as const,
|
||||
kind: "model_profile_probe" as const,
|
||||
operation_id: randomUUID(),
|
||||
profile_id: profileId,
|
||||
expected_profile_revision: profile.revision,
|
||||
});
|
||||
return { schema_version: 1, product: PRODUCT, state: "preview", plan };
|
||||
}
|
||||
|
||||
export async function executeModelProbe(
|
||||
value: unknown,
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
): Promise<ModelProbeFlowDocument> {
|
||||
const plan = validatePlan(value);
|
||||
if (!plan) return failure("invalid", "model_probe_plan_invalid");
|
||||
|
||||
// A 404 or a legacy/malformed body is not support. Some old Core versions
|
||||
// ignore the operation body and start an untracked external model request.
|
||||
const support = await reader<unknown>(operationPath(plan.operation_id));
|
||||
const support = await reader<unknown>(operationPath(operationId));
|
||||
if (support.state !== "ok") {
|
||||
return failure(
|
||||
"disabled",
|
||||
"model_probe_operations_unsupported",
|
||||
plan.operation_id,
|
||||
operationId,
|
||||
);
|
||||
}
|
||||
const supportReceipt = validateReceipt(support.data, plan.operation_id);
|
||||
const supportReceipt = validateReceipt(support.data, operationId);
|
||||
if (supportReceipt?.state !== "not_found") {
|
||||
return failure(
|
||||
"disabled",
|
||||
"model_probe_operations_unsupported",
|
||||
plan.operation_id,
|
||||
operationId,
|
||||
);
|
||||
}
|
||||
|
||||
const outcome = await operations.post<unknown>(
|
||||
`/api/v1/model-profiles/${plan.profile_id}/probe`,
|
||||
{
|
||||
operation_id: plan.operation_id,
|
||||
expected_profile_revision: plan.expected_profile_revision,
|
||||
},
|
||||
const posted = await operations.post<unknown>(
|
||||
`/api/v1/model-profiles/${profileId}/probe`,
|
||||
{ operation_id: operationId, expected_profile_revision: revision },
|
||||
);
|
||||
if (outcome.state === "rejected") {
|
||||
return failure("rejected", outcome.code, plan.operation_id);
|
||||
if (posted.state === "rejected") {
|
||||
return failure("rejected", posted.code, operationId);
|
||||
}
|
||||
if (outcome.state === "committed") {
|
||||
return receiptDocument(
|
||||
outcome.data,
|
||||
plan.operation_id,
|
||||
plan.profile_id,
|
||||
plan.expected_profile_revision,
|
||||
);
|
||||
if (posted.state === "committed") {
|
||||
return receiptDocument(posted.data, operationId, profileId, revision);
|
||||
}
|
||||
const reconciled = await readModelProbeReceipt(reader, plan.operation_id);
|
||||
const reconciled = await readModelProbeReceipt(reader, operationId);
|
||||
return reconciled.state === "not_found"
|
||||
? failure(
|
||||
"unknown",
|
||||
"model_probe_receipt_not_found_after_attempt",
|
||||
plan.operation_id,
|
||||
operationId,
|
||||
)
|
||||
: reconciled;
|
||||
}
|
||||
@@ -190,32 +162,24 @@ export async function readModelProbeReceipt(
|
||||
return receiptDocument(outcome.data, operationId);
|
||||
}
|
||||
|
||||
export function inspectModelProbePlan(value: unknown): ModelProbePlan | null {
|
||||
return validatePlan(value);
|
||||
}
|
||||
|
||||
export function modelProbeExitCode(document: ModelProbeFlowDocument): number {
|
||||
return ["preview", "completed"].includes(document.state) ? 0 : 1;
|
||||
// `pending` is a submitted probe still running: the submit succeeded.
|
||||
return ["completed", "pending"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderModelProbeText(document: ModelProbeFlowDocument): string {
|
||||
if (document.state === "preview") {
|
||||
return [
|
||||
"预览:模型能力探测",
|
||||
`模型配置:${document.plan.profile_id}`,
|
||||
`预期版本:${document.plan.expected_profile_revision}`,
|
||||
`操作 ID:${document.plan.operation_id}`,
|
||||
`确认摘要:${document.plan.digest}`,
|
||||
"未发起模型请求。请保存 --json 输出,再用 models probe --plan-file <path> --confirm。",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
const lines = [
|
||||
`探测状态:${document.state}`,
|
||||
`操作 ID:${document.operation_id ?? "无"}`,
|
||||
"code" in document ? `代码:${document.code}` : "已返回有界操作回执。",
|
||||
"",
|
||||
].join("\n");
|
||||
];
|
||||
if (document.state === "pending" && document.operation_id) {
|
||||
lines.push(
|
||||
`探测在后台进行;用 memhub models receipt ${document.operation_id} 查看结果。`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function receiptDocument(
|
||||
@@ -249,31 +213,6 @@ function receiptDocument(
|
||||
};
|
||||
}
|
||||
|
||||
function validatePlan(value: unknown): ModelProbePlan | null {
|
||||
const plan = record(value);
|
||||
if (
|
||||
plan?.schema_version !== 1 ||
|
||||
plan.kind !== "model_profile_probe" ||
|
||||
typeof plan.operation_id !== "string" ||
|
||||
!UUID.test(plan.operation_id) ||
|
||||
typeof plan.profile_id !== "string" ||
|
||||
!UUID.test(plan.profile_id) ||
|
||||
!positiveInteger(plan.expected_profile_revision) ||
|
||||
typeof plan.digest !== "string" ||
|
||||
!DIGEST.test(plan.digest)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const expected = withDigest({
|
||||
schema_version: 1 as const,
|
||||
kind: "model_profile_probe" as const,
|
||||
operation_id: plan.operation_id,
|
||||
profile_id: plan.profile_id,
|
||||
expected_profile_revision: plan.expected_profile_revision,
|
||||
});
|
||||
return expected.digest === plan.digest ? expected : null;
|
||||
}
|
||||
|
||||
function validateReceipt(
|
||||
value: unknown,
|
||||
operationId: string,
|
||||
@@ -409,27 +348,6 @@ function failure(
|
||||
};
|
||||
}
|
||||
|
||||
function withDigest<T extends object>(body: T): T & { digest: string } {
|
||||
return {
|
||||
...body,
|
||||
digest: createHash("sha256").update(canonical(body)).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map(
|
||||
(key) =>
|
||||
`${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`,
|
||||
)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
||||
@@ -41,7 +41,7 @@ export type ModelReportDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "denied" | "unavailable" | "not_found" | "invalid";
|
||||
state: "denied" | "unavailable" | "not_found" | "invalid" | "rejected";
|
||||
code: string;
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ function project(profile: ModelProfile): SafeModelProfile {
|
||||
}
|
||||
|
||||
function failure(
|
||||
state: "denied" | "unavailable" | "not_found" | "invalid",
|
||||
state: "denied" | "unavailable" | "not_found" | "invalid" | "rejected",
|
||||
code: string,
|
||||
): ModelReportDocument {
|
||||
return { schema_version: 1, product: PRODUCT, state, code };
|
||||
|
||||
@@ -126,29 +126,12 @@ describe("models activate", () => {
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews without contacting Core", async () => {
|
||||
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.state).toBe("preview");
|
||||
expect(document.plan.kind).toBe("model_profile_activate");
|
||||
// Activation has no CAS body, so the plan must not pretend to carry one.
|
||||
expect(document.plan).not.toHaveProperty("expected_revision");
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts the activate endpoint with --confirm and reports the profile", async () => {
|
||||
it("posts the activate endpoint directly and reports the profile", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: profileResponse(),
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"models",
|
||||
"activate",
|
||||
PROFILE,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
`/api/v1/model-profiles/${PROFILE}/activate`,
|
||||
@@ -196,31 +179,22 @@ describe("models activate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("models edit", () => {
|
||||
it("previews a patch from a config file without writing", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-model-edit-"));
|
||||
const config = path.join(directory, "patch.json");
|
||||
await writeFile(
|
||||
config,
|
||||
JSON.stringify({ expected_revision: 4, name: "Renamed" }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const result = await capturedRun([
|
||||
"models",
|
||||
"edit",
|
||||
PROFILE,
|
||||
"--config-file",
|
||||
config,
|
||||
"--json",
|
||||
]);
|
||||
describe("models remove", () => {
|
||||
it("previews without --yes and changes nothing", async () => {
|
||||
const result = await capturedRun(["models", "remove", PROFILE, "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.state).toBe("preview");
|
||||
expect(document.plan.patch).toEqual({ name: "Renamed" });
|
||||
expect(transport.patch).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "models remove",
|
||||
state: "preview",
|
||||
target: PROFILE,
|
||||
applied: false,
|
||||
});
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the exact patch body with the CAS revision on confirm", async () => {
|
||||
describe("models edit", () => {
|
||||
it("sends the exact patch body with the CAS revision directly", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-model-edit-"));
|
||||
const config = path.join(directory, "patch.json");
|
||||
await writeFile(
|
||||
@@ -242,7 +216,6 @@ describe("models edit", () => {
|
||||
PROFILE,
|
||||
"--config-file",
|
||||
config,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
* activate (`POST /model-profiles/{id}/activate`) and edit
|
||||
* (`PATCH /model-profiles/{id}`).
|
||||
*
|
||||
* Core owns the semantics. These flows reuse the same two-phase convention as
|
||||
* `models configure`: plan without `--confirm` (or produce a plan file), then
|
||||
* apply exactly that plan. The edit body mirrors the Web console's
|
||||
* `ModelProfilePatch`, including `expected_revision` as the compare-and-swap
|
||||
* guard Core requires.
|
||||
* Core owns the semantics. Both run directly: activation is reversible (activate
|
||||
* another profile) and an edit is guarded by `expected_revision`, the
|
||||
* compare-and-swap Core requires, mirroring the Web console's
|
||||
* `ModelProfilePatch`.
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { ModelProfile } from "@one-memory/contracts";
|
||||
import {
|
||||
buildModelProfileUpdateRequest,
|
||||
@@ -22,29 +20,7 @@ import { PRODUCT } from "./version.js";
|
||||
|
||||
const PROFILE_ID = /^[A-Za-z0-9][\w.-]{0,127}$/;
|
||||
|
||||
export type ModelWritePlan =
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "model_profile_activate";
|
||||
profile_id: string;
|
||||
digest: string;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "model_profile_edit";
|
||||
profile_id: string;
|
||||
expected_revision: number;
|
||||
patch: Record<string, unknown>;
|
||||
digest: string;
|
||||
};
|
||||
|
||||
export type ModelWriteDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "preview";
|
||||
plan: ModelWritePlan;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
@@ -60,38 +36,6 @@ export type ModelWriteDocument =
|
||||
code: string;
|
||||
};
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value !== null && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
if (value === undefined) return "null";
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function digestOf(value: unknown): string {
|
||||
return createHash("sha256").update(canonical(value)).digest("hex");
|
||||
}
|
||||
|
||||
function preview(
|
||||
plan:
|
||||
| Omit<
|
||||
Extract<ModelWritePlan, { kind: "model_profile_activate" }>,
|
||||
"digest"
|
||||
>
|
||||
| Omit<Extract<ModelWritePlan, { kind: "model_profile_edit" }>, "digest">,
|
||||
): ModelWriteDocument {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "preview",
|
||||
plan: { ...plan, digest: digestOf(plan) } as ModelWritePlan,
|
||||
};
|
||||
}
|
||||
|
||||
function invalid(code: string): ModelWriteDocument {
|
||||
return { schema_version: 1, product: PRODUCT, state: "invalid", code };
|
||||
}
|
||||
@@ -122,87 +66,45 @@ function projectProfile(profile: ModelProfile): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
// --- activate -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Activation has no request body, so the plan carries only the target. There
|
||||
* is deliberately no `expected_revision`: Core would never read one, and
|
||||
* recording a check it does not perform would be a fake guard.
|
||||
*/
|
||||
export function planModelProfileActivate(input: {
|
||||
profileId: string;
|
||||
}): ModelWriteDocument {
|
||||
return PROFILE_ID.test(input.profileId)
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
kind: "model_profile_activate",
|
||||
profile_id: input.profileId,
|
||||
})
|
||||
: invalid("model_profile_activate_input_invalid");
|
||||
}
|
||||
|
||||
export async function executeModelProfileActivate(
|
||||
plan: Extract<ModelWritePlan, { kind: "model_profile_activate" }>,
|
||||
/** Make `profileId` the active profile. Activation has no request body, so
|
||||
* there is no revision for Core to check. */
|
||||
export async function activateModelProfile(
|
||||
profileId: string,
|
||||
operations: CoreOperations,
|
||||
): Promise<ModelWriteDocument> {
|
||||
if (!PROFILE_ID.test(profileId)) {
|
||||
return invalid("model_profile_activate_input_invalid");
|
||||
}
|
||||
const result = await operations.post<unknown>(
|
||||
`/api/v1/model-profiles/${encodeURIComponent(plan.profile_id)}/activate`,
|
||||
`/api/v1/model-profiles/${encodeURIComponent(profileId)}/activate`,
|
||||
{},
|
||||
);
|
||||
return modelProfileResult(plan.profile_id, result);
|
||||
return modelProfileResult(profileId, result);
|
||||
}
|
||||
|
||||
// --- edit -----------------------------------------------------------------
|
||||
|
||||
export function planModelProfileEdit(input: {
|
||||
profileId: string;
|
||||
patch: unknown;
|
||||
}): ModelWriteDocument {
|
||||
// The shared builder owns patch validation (14-key allow-list,
|
||||
// expected_revision CAS, field bounds, api_key/clear_api_key exclusivity).
|
||||
// A rejected patch is `invalid`, never a plan the caller could confirm.
|
||||
let request: ReturnType<typeof buildModelProfileUpdateRequest>;
|
||||
try {
|
||||
request = buildModelProfileUpdateRequest(input.profileId, input.patch);
|
||||
} catch (error) {
|
||||
if (error instanceof ModelProfileInputError) {
|
||||
return invalid("model_profile_edit_input_invalid");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const patch = request.body as Record<string, unknown>;
|
||||
if (!PROFILE_ID.test(input.profileId)) {
|
||||
/** Apply a bounded patch (carrying `expected_revision`) to one profile; the
|
||||
* key, when given, comes from a controlled file and never from the patch. */
|
||||
export async function editModelProfile(
|
||||
profileId: string,
|
||||
patch: unknown,
|
||||
apiKey: string | undefined,
|
||||
operations: CorePatchOperations,
|
||||
): Promise<ModelWriteDocument> {
|
||||
if (
|
||||
!PROFILE_ID.test(profileId) ||
|
||||
patch === null ||
|
||||
typeof patch !== "object" ||
|
||||
Array.isArray(patch) ||
|
||||
"api_key" in patch
|
||||
) {
|
||||
return invalid("model_profile_edit_input_invalid");
|
||||
}
|
||||
return preview({
|
||||
schema_version: 1,
|
||||
kind: "model_profile_edit",
|
||||
profile_id: input.profileId,
|
||||
expected_revision: patch.expected_revision as number,
|
||||
// `api_key` is read from a controlled file at apply time, exactly like
|
||||
// `models configure`; the plan never carries the credential. The revision
|
||||
// is a top-level field, so it is stripped from the nested patch rather
|
||||
// than being duplicated in both places.
|
||||
patch: Object.fromEntries(
|
||||
Object.entries(patch).filter(
|
||||
([key]) => key !== "api_key" && key !== "expected_revision",
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeModelProfileEdit(
|
||||
plan: Extract<ModelWritePlan, { kind: "model_profile_edit" }>,
|
||||
operations: CorePatchOperations,
|
||||
apiKey: string | undefined,
|
||||
): Promise<ModelWriteDocument> {
|
||||
// Re-validate through the shared builder at apply time too, so a hand-edited
|
||||
// plan cannot smuggle an out-of-bounds patch past the write.
|
||||
// The shared builder owns patch validation (allow-list, expected_revision
|
||||
// CAS, field bounds, api_key/clear_api_key exclusivity).
|
||||
let request: ReturnType<typeof buildModelProfileUpdateRequest>;
|
||||
try {
|
||||
request = buildModelProfileUpdateRequest(plan.profile_id, {
|
||||
...plan.patch,
|
||||
expected_revision: plan.expected_revision,
|
||||
request = buildModelProfileUpdateRequest(profileId, {
|
||||
...(patch as Record<string, unknown>),
|
||||
...(apiKey === undefined ? {} : { api_key: apiKey }),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -212,7 +114,7 @@ export async function executeModelProfileEdit(
|
||||
throw error;
|
||||
}
|
||||
const result = await operations.patch<unknown>(request.path, request.body);
|
||||
return modelProfileResult(plan.profile_id, result);
|
||||
return modelProfileResult(profileId, result);
|
||||
}
|
||||
|
||||
function modelProfileResult(
|
||||
@@ -254,69 +156,11 @@ function modelProfileResult(
|
||||
return committed(operationId, profile);
|
||||
}
|
||||
|
||||
export function validateModelWritePlan(value: unknown): ModelWritePlan | null {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.schema_version !== 1 || typeof candidate.kind !== "string") {
|
||||
return null;
|
||||
}
|
||||
const { digest, ...withoutDigest } = candidate;
|
||||
if (typeof digest !== "string" || digest !== digestOf(withoutDigest)) {
|
||||
return null;
|
||||
}
|
||||
switch (candidate.kind) {
|
||||
case "model_profile_activate":
|
||||
return typeof candidate.profile_id === "string" &&
|
||||
PROFILE_ID.test(candidate.profile_id) &&
|
||||
Object.keys(candidate).length === 3
|
||||
? (candidate as unknown as ModelWritePlan)
|
||||
: null;
|
||||
case "model_profile_edit": {
|
||||
if (
|
||||
typeof candidate.profile_id !== "string" ||
|
||||
!PROFILE_ID.test(candidate.profile_id) ||
|
||||
!Number.isSafeInteger(candidate.expected_revision)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// The shared builder is the single judge of a patch body; the plan must
|
||||
// not carry `api_key` (that is read from a controlled file at apply).
|
||||
let built: ReturnType<typeof buildModelProfileUpdateRequest>;
|
||||
try {
|
||||
built = buildModelProfileUpdateRequest(candidate.profile_id, {
|
||||
...(candidate.patch as Record<string, unknown>),
|
||||
expected_revision: candidate.expected_revision,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if ("api_key" in built.body) return null;
|
||||
return Object.keys(candidate).length === 6
|
||||
? (candidate as unknown as ModelWritePlan)
|
||||
: null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function modelWriteExitCode(document: ModelWriteDocument): number {
|
||||
return ["preview", "committed"].includes(document.state) ? 0 : 1;
|
||||
return document.state === "committed" ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderModelWriteText(document: ModelWriteDocument): string {
|
||||
if (document.state === "preview") {
|
||||
const plan = document.plan;
|
||||
return [
|
||||
`预览:${plan.kind}`,
|
||||
`目标配置:${plan.profile_id}`,
|
||||
`确认摘要:${plan.digest}`,
|
||||
"未执行任何写操作。请把 --json 输出保存为 plan 文件,再用对应命令加 --confirm。",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
if (document.state === "committed") {
|
||||
return `已保存模型配置:${document.receipt.profile_id}\nrevision=${document.receipt.revision}\n`;
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { constants } from "node:fs";
|
||||
import { type FileHandle, open } from "node:fs/promises";
|
||||
import { readBoundedJson } from "./bounded-file.js";
|
||||
|
||||
export type PlanAttempt = {
|
||||
schema_version: 1;
|
||||
operation_id: string;
|
||||
plan_digest: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Durable client fence written before a mutating request. Once present, the
|
||||
* same plan may only query its operation receipt; it is never blindly posted
|
||||
* again after an unknown result.
|
||||
*/
|
||||
export async function claimPlanAttempt(
|
||||
planFile: string,
|
||||
attempt: PlanAttempt,
|
||||
): Promise<"first" | "prior" | "invalid"> {
|
||||
const journal = `${planFile}.one-memory-attempt`;
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await open(
|
||||
journal,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return "invalid";
|
||||
const current = await readBoundedJson(journal, 1024);
|
||||
return sameAttempt(current, attempt) ? "prior" : "invalid";
|
||||
}
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(attempt)}\n`, "utf8");
|
||||
await handle.sync();
|
||||
return "first";
|
||||
} catch {
|
||||
return "invalid";
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function sameAttempt(value: unknown, expected: PlanAttempt): boolean {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
(value as Record<string, unknown>).schema_version === 1 &&
|
||||
(value as Record<string, unknown>).operation_id === expected.operation_id &&
|
||||
(value as Record<string, unknown>).plan_digest === expected.plan_digest &&
|
||||
Object.keys(value).length === 3
|
||||
);
|
||||
}
|
||||
@@ -104,7 +104,7 @@ describe("package matrix harness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on every destructive UI-write command without a Runtime", {
|
||||
it("fails closed on every write command without a Runtime", {
|
||||
timeout: 120_000,
|
||||
}, async () => {
|
||||
const runtimeDist = await mkdtemp(
|
||||
@@ -140,7 +140,7 @@ describe("package matrix harness", () => {
|
||||
expect(document.state).toBe("verified");
|
||||
const results = document.commands.stopped_destructive.results;
|
||||
// Every destructive command fails closed while Runtime is stopped.
|
||||
expect(Object.keys(results)).toHaveLength(7);
|
||||
expect(Object.keys(results)).toHaveLength(8);
|
||||
for (const [name, result] of Object.entries(results)) {
|
||||
expect(result, `${name} did not fail closed`).toEqual({
|
||||
exit: 3,
|
||||
@@ -164,7 +164,7 @@ describe("package matrix harness", () => {
|
||||
const shim = [
|
||||
"#!/usr/bin/env node",
|
||||
"const a=process.argv.slice(2);",
|
||||
"if(['calls','notes','graph','models','memories'].includes(a[0])){",
|
||||
"if(['graph-width','models','memories','sources'].includes(a[0])){",
|
||||
' process.stdout.write(\'{"schema_version":1,"product":"memhub","state":"committed","operation_id":"x","receipt":{}}\\n\');',
|
||||
" process.exit(0);",
|
||||
"}",
|
||||
@@ -190,7 +190,7 @@ describe("package matrix harness", () => {
|
||||
expect(failure?.state).toBe("failed");
|
||||
expect(
|
||||
failure?.failures.filter((item) => item.includes("did not fail closed")),
|
||||
).toHaveLength(7);
|
||||
).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("can require an expected bundle SHA and reports bundle metadata", {
|
||||
@@ -291,9 +291,7 @@ describe("package matrix harness", () => {
|
||||
"sync",
|
||||
"memories",
|
||||
"profile",
|
||||
"calls",
|
||||
"notes",
|
||||
"graph",
|
||||
"graph-width",
|
||||
"models",
|
||||
"host",
|
||||
"doctor",
|
||||
@@ -311,14 +309,12 @@ describe("package matrix harness", () => {
|
||||
"uninstall",
|
||||
"delete-data",
|
||||
"recall",
|
||||
"setup",
|
||||
"scan",
|
||||
"progress",
|
||||
"jobs",
|
||||
"path",
|
||||
"scan-roots",
|
||||
"adapter",
|
||||
"write",
|
||||
"task",
|
||||
"serve",
|
||||
];
|
||||
const catalog = {
|
||||
|
||||
@@ -145,13 +145,15 @@ describe("memhub short-command contract", () => {
|
||||
[command.usage, command.alternative_usage ?? ""].join("\n"),
|
||||
).join("\n");
|
||||
for (const shortForm of [
|
||||
"memhub calls clear",
|
||||
"memhub notes add",
|
||||
"memhub notes process",
|
||||
"memhub memories add",
|
||||
"memhub memories add-status",
|
||||
"memhub models setup",
|
||||
"memhub models add",
|
||||
"memhub models test",
|
||||
"memhub models activate",
|
||||
"memhub models edit",
|
||||
"memhub graph width",
|
||||
"memhub graph-width",
|
||||
"memhub sources connect",
|
||||
"memhub write submit",
|
||||
"memhub write status",
|
||||
"memhub memories update",
|
||||
@@ -159,6 +161,10 @@ describe("memhub short-command contract", () => {
|
||||
]) {
|
||||
expect(flattened).toContain(shortForm);
|
||||
}
|
||||
// No "note" concept and no plan files on the CLI surface.
|
||||
for (const removed of ["memhub notes", "memhub calls", "--plan-file"]) {
|
||||
expect(flattened).not.toContain(removed);
|
||||
}
|
||||
expect(COMMANDS.some((command) => command.name === "permission")).toBe(
|
||||
false,
|
||||
);
|
||||
@@ -211,7 +217,7 @@ describe("memhub short-command contract", () => {
|
||||
// but an earlier catalog text still claimed they were "尚不可用". A user
|
||||
// reading `help path` would not try a command that works.
|
||||
it("does not call the wired path/adapter commands unavailable", () => {
|
||||
for (const name of ["path", "adapter"]) {
|
||||
for (const name of ["scan-roots", "adapter"]) {
|
||||
const command = COMMANDS.find((entry) => entry.name === name);
|
||||
expect(command).toBeDefined();
|
||||
const text = [
|
||||
@@ -277,7 +283,7 @@ describe("memhub short-command contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps plan-file apply previews free of diagnostic side effects", {
|
||||
it("keeps destructive previews free of diagnostic side effects", {
|
||||
timeout: 20_000,
|
||||
}, async () => {
|
||||
expect(
|
||||
@@ -291,9 +297,10 @@ describe("memhub short-command contract", () => {
|
||||
);
|
||||
try {
|
||||
for (const [index, argv] of [
|
||||
["sources", "import", "--plan-file", path.join(root, "plan.json")],
|
||||
["models", "configure", "--plan-file", path.join(root, "plan.json")],
|
||||
["memories", "apply", "--plan-file", path.join(root, "plan.json")],
|
||||
["memories", "delete", "m-1"],
|
||||
["models", "remove", "44444444-4444-4444-8444-444444444444"],
|
||||
["backup", "restore", "--source", path.join(root, "backup.sqlite3")],
|
||||
["graph-width", "--decision", "rebuild_with_new"],
|
||||
].entries()) {
|
||||
const dataDir = path.join(root, `data-${index}`);
|
||||
await import("node:fs/promises").then(({ mkdir }) =>
|
||||
@@ -305,8 +312,8 @@ describe("memhub short-command contract", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// The missing plan or stopped Runtime is expected; the guard is
|
||||
// that even the failure path did not add a diagnostic file.
|
||||
// A failure path is acceptable; the guard is that no preview adds
|
||||
// a diagnostic file.
|
||||
}
|
||||
expect(await readdir(dataDir)).toEqual([]);
|
||||
}
|
||||
|
||||
@@ -1,122 +1,56 @@
|
||||
// A6: the rules behind the six product commands.
|
||||
// The rules behind the commands that attach to the Runtime directly.
|
||||
//
|
||||
// The parsers are where the contract actually lives — "needs --yes to change
|
||||
// anything" and "no write without evidence" are decisions, not formatting — so
|
||||
// they are tested directly rather than only through the CLI.
|
||||
// The parsers are where the contract actually lives — "only irreversible
|
||||
// operations ask for --yes" and "unknown flags are rejected" are decisions,
|
||||
// not formatting — so they are tested directly rather than only through the
|
||||
// CLI.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseOpenAIMessages } from "./core-client.js";
|
||||
import {
|
||||
asPlanDocument,
|
||||
describe as describePlan,
|
||||
parseProductCommand,
|
||||
parseBackupCommand,
|
||||
parseScanCommand,
|
||||
parseScanRootsCommand,
|
||||
parseTaskCommand,
|
||||
parseWriteCommand,
|
||||
restoreConfirmationMatches,
|
||||
} from "./product-flows.js";
|
||||
|
||||
describe("mutating commands are two-phase", () => {
|
||||
it("plans without --yes", () => {
|
||||
const parsed = parseProductCommand("scan", ["--root", "/tmp/a"]);
|
||||
expect(parsed?.confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it("confirms with --yes", () => {
|
||||
const parsed = parseProductCommand("scan", ["--root", "/tmp/a", "--yes"]);
|
||||
expect(parsed?.confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it("says what it would do", () => {
|
||||
const parsed = parseProductCommand("scan", ["--root", "/tmp/a"]);
|
||||
// `?.plan!` would let a null parse pass silently; assert the parse first.
|
||||
if (parsed === null) {
|
||||
throw new Error("scan with a root must parse");
|
||||
}
|
||||
expect(describePlan(parsed.plan)).toContain("scan: /tmp/a");
|
||||
});
|
||||
|
||||
it("marks a plan as not applied", () => {
|
||||
// Assert the parse itself: `?.` let a null result satisfy this test for as
|
||||
// long as `backup create` was unparseable.
|
||||
const parsed = parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/b",
|
||||
]);
|
||||
if (parsed === null) {
|
||||
throw new Error("backup create must parse");
|
||||
}
|
||||
expect(parsed.plan.verb).toBe("create");
|
||||
expect(asPlanDocument(parsed.plan).applied).toBe(false);
|
||||
});
|
||||
|
||||
it("parses the verb for every command that takes one", () => {
|
||||
// `path add` / `path remove` / `backup create` were rejected outright
|
||||
// before the verb was skipped in parsing.
|
||||
describe("scan-roots", () => {
|
||||
it("reads the root, source and recursive flag off add", () => {
|
||||
expect(
|
||||
parseProductCommand("path", ["add", "--root", "/tmp/x"])?.plan.action,
|
||||
).toBe("add scan root");
|
||||
expect(
|
||||
parseProductCommand("path", ["remove", "--root", "/tmp/x"])?.plan.action,
|
||||
).toBe("remove scan root");
|
||||
expect(
|
||||
parseProductCommand("backup", ["restore", "--source", "/tmp/b"])?.plan
|
||||
.action,
|
||||
).toBe("restore backup");
|
||||
parseScanRootsCommand([
|
||||
"add",
|
||||
"--root",
|
||||
"/tmp/x",
|
||||
"--source",
|
||||
"codex",
|
||||
"--no-recursive",
|
||||
]),
|
||||
).toEqual({
|
||||
kind: "add",
|
||||
root: "/tmp/x",
|
||||
source: "codex",
|
||||
recursive: false,
|
||||
});
|
||||
expect(parseScanRootsCommand(["add", "--root", "/tmp/x"])).toEqual({
|
||||
kind: "add",
|
||||
root: "/tmp/x",
|
||||
recursive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults a scan to all registered roots", () => {
|
||||
const parsed = parseProductCommand("scan", []);
|
||||
expect(parsed?.plan.target).toBe("all registered roots");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown flags are rejected", () => {
|
||||
it("rejects a typo'd flag rather than ignoring it", () => {
|
||||
// A silently ignored --yes would turn a real apply into a plan, and the
|
||||
// user would believe the change had been made.
|
||||
expect(parseProductCommand("scan", ["--yesx"])).toBeNull();
|
||||
expect(parseProductCommand("scan", ["--root"])).toBeNull();
|
||||
expect(parseProductCommand("scan", ["--root", "--yes"])).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unknown verb for backup", () => {
|
||||
it("distinguishes remove from add and refuses add-only flags on remove", () => {
|
||||
expect(parseScanRootsCommand(["remove", "--root", "/tmp/x"])).toEqual({
|
||||
kind: "remove",
|
||||
root: "/tmp/x",
|
||||
});
|
||||
expect(
|
||||
parseProductCommand("backup", ["make", "--target", "/tmp/b"]),
|
||||
parseScanRootsCommand(["remove", "--root", "/tmp/x", "--no-recursive"]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// `path` and `scan` share one parser. Before this guard, an unknown verb was
|
||||
// silently treated as `path add` or a default `scan`, so `--yes` executed a
|
||||
// different operation than the one the caller named.
|
||||
it("rejects an unknown verb for path without turning it into add", () => {
|
||||
expect(
|
||||
parseProductCommand("path", ["frobnicate", "--root", "/tmp/x", "--yes"]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unknown verb for scan without turning it into all", () => {
|
||||
expect(
|
||||
parseProductCommand("scan", ["frobnicate", "--all", "--yes"]),
|
||||
).toBeNull();
|
||||
expect(parseProductCommand("scan", ["frobnicate", "--yes"])).toBeNull();
|
||||
});
|
||||
|
||||
it("still accepts the documented verbless scan", () => {
|
||||
expect(parseProductCommand("scan", ["--all", "--yes"])?.plan.all).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
parseProductCommand("scan", ["--source", "codex"])?.plan.sourceId,
|
||||
).toBe("codex");
|
||||
});
|
||||
|
||||
it("rejects flags that the named verb does not consume", () => {
|
||||
// These were accepted and then silently dropped. An operator who typed
|
||||
// `path remove --source codex` would believe the source filter applied.
|
||||
expect(
|
||||
parseProductCommand("path", [
|
||||
parseScanRootsCommand([
|
||||
"remove",
|
||||
"--root",
|
||||
"/tmp/x",
|
||||
@@ -124,137 +58,103 @@ describe("unknown flags are rejected", () => {
|
||||
"codex",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is direct, but keeps accepting a legacy --yes", () => {
|
||||
expect(parseScanRootsCommand(["add", "--root", "/tmp/x", "--yes"])).toEqual(
|
||||
{ kind: "add", root: "/tmp/x", recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown verbs, typos and repeated flags", () => {
|
||||
expect(parseScanRootsCommand(["create", "--root", "/tmp/x"])).toBeNull();
|
||||
expect(parseScanRootsCommand(["add", "--rot", "/tmp/x"])).toBeNull();
|
||||
expect(parseScanRootsCommand(["add"])).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("path", [
|
||||
"remove",
|
||||
"--root",
|
||||
"/tmp/x",
|
||||
"--no-recursive",
|
||||
]),
|
||||
parseScanRootsCommand(["add", "--root", "/a", "--root", "/b"]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("path", ["add", "--root", "/tmp/x", "--full"]),
|
||||
parseScanRootsCommand(["add", "--root", "/a", "--yes", "--yes"]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scan", () => {
|
||||
it("needs a source: there is no endpoint that scans a bare path", () => {
|
||||
expect(parseScanCommand(["--source", "codex"])).toEqual({
|
||||
kind: "source",
|
||||
sourceId: "codex",
|
||||
});
|
||||
expect(parseScanCommand([])).toBeNull();
|
||||
expect(parseScanCommand(["--root", "/tmp/a"])).toBeNull();
|
||||
});
|
||||
|
||||
it("no longer accepts --full, which was never sent to Core", () => {
|
||||
expect(parseScanCommand(["--source", "codex", "--full"])).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps --all as a hidden alias and a legacy --yes", () => {
|
||||
expect(parseScanCommand(["--all", "--yes"])).toEqual({ kind: "all" });
|
||||
expect(parseScanCommand(["--all", "--source", "codex"])).toBeNull();
|
||||
expect(parseScanCommand(["--source", "codex", "--yes"])).toEqual({
|
||||
kind: "source",
|
||||
sourceId: "codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a verb, a bad source id and repeated flags", () => {
|
||||
expect(parseScanCommand(["all"])).toBeNull();
|
||||
expect(parseScanCommand(["--source", "Bad Id"])).toBeNull();
|
||||
expect(
|
||||
parseScanCommand(["--source", "codex", "--source", "cursor"]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/b",
|
||||
"--source",
|
||||
"/tmp/s",
|
||||
]),
|
||||
parseScanCommand(["--source", "codex", "--json", "--json"]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("backup", () => {
|
||||
it("creates directly and requires a target", () => {
|
||||
expect(parseBackupCommand(["create", "--target", "/tmp/b"])).toEqual({
|
||||
verb: "create",
|
||||
target: "/tmp/b",
|
||||
});
|
||||
expect(parseBackupCommand(["create"])).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", [
|
||||
"restore",
|
||||
"--source",
|
||||
"/tmp/s",
|
||||
"--target",
|
||||
"/tmp/t",
|
||||
]),
|
||||
parseBackupCommand(["create", "--target", "/tmp/b", "--source", "/x"]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// A later value silently won before this guard. For `scan --source` that
|
||||
// means one extra shell/make variable can change which source is scanned
|
||||
// while the operator believes both were applied.
|
||||
it("rejects repeated value-bearing flags instead of letting the last one win", () => {
|
||||
it("restores only with --yes and requires a source", () => {
|
||||
expect(parseBackupCommand(["restore", "--source", "/tmp/b"])).toEqual({
|
||||
verb: "restore",
|
||||
source: "/tmp/b",
|
||||
confirmed: false,
|
||||
});
|
||||
expect(
|
||||
parseProductCommand("scan", ["--source", "codex", "--source", "cursor"]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("path", [
|
||||
"add",
|
||||
"--root",
|
||||
"/tmp/a",
|
||||
"--root",
|
||||
"/tmp/b",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/a",
|
||||
"--target",
|
||||
"/tmp/b",
|
||||
]),
|
||||
).toBeNull();
|
||||
parseBackupCommand(["restore", "--source", "/tmp/b", "--yes"]),
|
||||
).toMatchObject({ confirmed: true });
|
||||
expect(parseBackupCommand(["restore", "--yes"])).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects repeated boolean flags instead of treating them as idempotent", () => {
|
||||
expect(parseProductCommand("scan", ["--all", "--all", "--yes"])).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("scan", ["--full", "--full", "--yes"]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("path", [
|
||||
"add",
|
||||
"--root",
|
||||
"/tmp/a",
|
||||
"--no-recursive",
|
||||
"--no-recursive",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/a",
|
||||
"--yes",
|
||||
"--yes",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a repeated global --json spelling", () => {
|
||||
expect(
|
||||
parseProductCommand("scan", ["--root", "/tmp/a", "--json", "--json"]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/a",
|
||||
"--json",
|
||||
"--json",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a source when restoring", () => {
|
||||
expect(
|
||||
parseProductCommand("backup", ["restore", "--target", "/tmp/b"]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parses the explicit restore confirmation path", () => {
|
||||
const parsed = parseProductCommand("backup", [
|
||||
it("accepts the legacy --confirm <path> and checks it lexically", () => {
|
||||
const parsed = parseBackupCommand([
|
||||
"restore",
|
||||
"--source",
|
||||
"/tmp/backups/a.sqlite3",
|
||||
"/tmp/b",
|
||||
"--yes",
|
||||
"--confirm",
|
||||
"/tmp/backups/a.sqlite3",
|
||||
"/tmp/b",
|
||||
]);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.confirmPath).toBe("/tmp/backups/a.sqlite3");
|
||||
expect(
|
||||
restoreConfirmationMatches("/tmp/backups/a.sqlite3", parsed?.confirmPath),
|
||||
).toBe(true);
|
||||
expect(
|
||||
restoreConfirmationMatches("/tmp/backups/a.sqlite3", undefined),
|
||||
).toBe(false);
|
||||
expect(
|
||||
restoreConfirmationMatches(
|
||||
"/tmp/backups/a.sqlite3",
|
||||
"/tmp/backups/../backups/a.sqlite3",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(parsed).toMatchObject({ confirmPath: "/tmp/b" });
|
||||
expect(restoreConfirmationMatches("/tmp/b", "/tmp/b")).toBe(true);
|
||||
expect(restoreConfirmationMatches("/tmp/b", undefined)).toBe(true);
|
||||
expect(restoreConfirmationMatches("/tmp/b", "/tmp/./b")).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a target when creating a backup", () => {
|
||||
expect(parseProductCommand("backup", ["create"])).toBeNull();
|
||||
it("rejects an unknown verb", () => {
|
||||
expect(parseBackupCommand(["delete", "--target", "/tmp/b"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,11 +205,19 @@ describe("write", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parses status under write", () => {
|
||||
it("parses status under write, positional or --id", () => {
|
||||
expect(parseWriteCommand(["status", "--id", "request-1"])).toEqual({
|
||||
kind: "status",
|
||||
id: "request-1",
|
||||
});
|
||||
expect(parseWriteCommand(["status", "request-1"])).toEqual({
|
||||
kind: "status",
|
||||
id: "request-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a repeated --session instead of letting the last one win", () => {
|
||||
expect(parseWriteCommand([...base, "--session", "s2"])).toBeNull();
|
||||
});
|
||||
|
||||
it("marks --live as priority live", () => {
|
||||
@@ -366,53 +274,3 @@ describe("task", () => {
|
||||
expect(parseTaskCommand(["--id", "req-1", "--json", "--json"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("path add and remove carry what the endpoint needs", () => {
|
||||
it("reads the source and recursive flag off the path add", () => {
|
||||
const parsed = parseProductCommand("path", [
|
||||
"add",
|
||||
"--root",
|
||||
"/tmp/scanme",
|
||||
"--source",
|
||||
"codex",
|
||||
"--no-recursive",
|
||||
]);
|
||||
// `?.plan!` let a null parse pass silently, so assert the parse itself.
|
||||
if (parsed === null) {
|
||||
throw new Error("path add must parse");
|
||||
}
|
||||
expect(parsed.plan.action).toBe("add scan root");
|
||||
expect(parsed.plan.target).toBe("/tmp/scanme");
|
||||
expect(parsed.plan.detail).toContain("source=codex");
|
||||
expect(parsed.plan.detail).toContain("recursive=false");
|
||||
});
|
||||
|
||||
it("distinguishes remove from add", () => {
|
||||
const parsed = parseProductCommand("path", [
|
||||
"remove",
|
||||
"--root",
|
||||
"/tmp/scanme",
|
||||
]);
|
||||
if (parsed === null) {
|
||||
throw new Error("path remove must parse");
|
||||
}
|
||||
// The action is the discriminator at apply time: add and remove share a
|
||||
// `--root`, so a mis-set action would register the directory it meant to delete.
|
||||
expect(parsed.plan.action).toBe("remove scan root");
|
||||
});
|
||||
|
||||
it("does not silently drop --yes", () => {
|
||||
const parsed = parseProductCommand("path", [
|
||||
"add",
|
||||
"--root",
|
||||
"/tmp/a",
|
||||
"--yes",
|
||||
]);
|
||||
expect(parsed?.confirmed).toBe(true);
|
||||
// Without this, a typo'd `--yes` would fall through to a plan and the user
|
||||
// would believe the change had been made.
|
||||
|
||||
const without = parseProductCommand("path", ["add", "--root", "/tmp/a"]);
|
||||
expect(without?.confirmed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,258 +1,163 @@
|
||||
// A6: the product commands Runtime exposes on the CLI.
|
||||
// Parsers for the commands that attach to the Runtime directly:
|
||||
// `scan-roots` (alias `path`), `scan`, `backup`, `write`.
|
||||
//
|
||||
// These are the six commands the objective names: path, adapter, scan, write,
|
||||
// task, backup. Two rules run through all of them:
|
||||
// Rules shared by all of them:
|
||||
//
|
||||
// - **Mutating commands are two-phase.** Without `--yes` they print a plan and
|
||||
// change nothing. This is the convention the existing install/service
|
||||
// commands already follow, and it is why `scan` and `backup` default to a
|
||||
// plan rather than quietly doing work.
|
||||
// - **Output is machine-readable.** `--json` emits one document on stdout so an
|
||||
// agent can consume it; the human form is text. Never both, so a caller that
|
||||
// parses stdout is not surprised by prose.
|
||||
// - **Only irreversible operations ask for `--yes`.** `backup restore`
|
||||
// overwrites the database, so without `--yes` it previews and changes
|
||||
// nothing. Registering a scan root, scanning a source, creating a backup and
|
||||
// submitting a write are direct. A legacy `--yes` on a direct command is
|
||||
// accepted and ignored so older scripts keep working.
|
||||
// - **Unknown or repeated flags are rejected**, never ignored: a dropped typo
|
||||
// would silently change what the command does.
|
||||
// - **Output is machine-readable.** `--json` emits one document on stdout.
|
||||
//
|
||||
// The commands talk to the Runtime's HTTP API, not to the database and not to
|
||||
// HY. That is the A6 rule: CLI and Electron are clients of the same Runtime.
|
||||
// the SDK: CLI and desktop are clients of the same Runtime.
|
||||
|
||||
/** What a mutating command would do, before it is allowed to do it. */
|
||||
export interface Plan {
|
||||
readonly action: string;
|
||||
readonly target: string;
|
||||
readonly detail?: string;
|
||||
/** Which form of the command this plan is for, when one name has several.
|
||||
*
|
||||
* `backup` has `create` and `restore`, and applying the wrong one is not a
|
||||
* harmless mistake — so the verb travels with the plan the user confirmed.
|
||||
*/
|
||||
readonly verb?: string;
|
||||
/** The source the Runtime endpoint addresses, when the plan names one.
|
||||
*
|
||||
* Carried on the plan rather than re-parsed at apply time so the plan the
|
||||
* user confirmed and the request actually sent cannot disagree.
|
||||
*/
|
||||
readonly sourceId?: string;
|
||||
/** `scan --all`: every source Runtime actually found, not a named one. */
|
||||
readonly all?: boolean;
|
||||
export type ScanRootsCommand =
|
||||
| {
|
||||
readonly kind: "add";
|
||||
readonly root: string;
|
||||
readonly source?: string;
|
||||
readonly recursive: boolean;
|
||||
}
|
||||
| { readonly kind: "remove"; readonly root: string };
|
||||
|
||||
export type ScanCommand =
|
||||
| { readonly kind: "source"; readonly sourceId: string }
|
||||
/** Hidden alias of `sources connect --all-detected --import`. */
|
||||
| { readonly kind: "all" };
|
||||
|
||||
export type BackupCommand =
|
||||
| { readonly verb: "create"; readonly target: string }
|
||||
| {
|
||||
readonly verb: "restore";
|
||||
readonly source: string;
|
||||
readonly confirmed: boolean;
|
||||
/** Legacy `--confirm <path>`: accepted, and must repeat `--source`. */
|
||||
readonly confirmPath?: string;
|
||||
};
|
||||
|
||||
interface ParsedFlags {
|
||||
readonly values: Map<string, string>;
|
||||
readonly switches: Set<string>;
|
||||
}
|
||||
|
||||
export function planAddRoot(
|
||||
root: string,
|
||||
source: string | undefined,
|
||||
recursive: boolean,
|
||||
): Plan {
|
||||
return {
|
||||
action: "add scan root",
|
||||
target: root,
|
||||
detail: `source=${source ?? "auto"} recursive=${recursive}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function planRemoveRoot(root: string): Plan {
|
||||
return { action: "remove scan root", target: root };
|
||||
}
|
||||
|
||||
export function planScan(root: string | undefined, full: boolean): Plan {
|
||||
return {
|
||||
action: "scan",
|
||||
target: root ?? "all registered roots",
|
||||
detail: `full=${full}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function planScanSource(sourceId: string, full: boolean): Plan {
|
||||
return {
|
||||
action: "scan",
|
||||
target: `source ${sourceId}`,
|
||||
detail: `full=${full}`,
|
||||
sourceId,
|
||||
};
|
||||
}
|
||||
|
||||
export function planScanAll(full: boolean): Plan {
|
||||
return {
|
||||
action: "scan",
|
||||
target: "all detected sources",
|
||||
detail: `full=${full}`,
|
||||
all: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function planBackup(target: string): Plan {
|
||||
return { action: "create backup", target, verb: "create" };
|
||||
}
|
||||
|
||||
export function planRestore(source: string): Plan {
|
||||
return { action: "restore backup", target: source, verb: "restore" };
|
||||
}
|
||||
|
||||
export interface ProductCommand {
|
||||
readonly plan: Plan;
|
||||
readonly confirmed: boolean;
|
||||
/** For destructive verbs with a named artifact, the exact value repeated by
|
||||
* the user. `backup restore` requires this to match `--source`. */
|
||||
readonly confirmPath?: string;
|
||||
}
|
||||
|
||||
/** Parse a mutating product command.
|
||||
*
|
||||
* Unknown flags are rejected rather than ignored: a typo'd `--yes` that is
|
||||
* silently dropped would turn a real apply into a plan, and the user would
|
||||
* believe the change had been made.
|
||||
*/
|
||||
export function parseProductCommand(
|
||||
name: string,
|
||||
/** Read `--flag value` pairs and bare switches; null on anything unknown,
|
||||
* repeated or missing its value. */
|
||||
function parseFlags(
|
||||
args: readonly string[],
|
||||
): ProductCommand | null {
|
||||
let root: string | undefined;
|
||||
let source: string | undefined;
|
||||
let target: string | undefined;
|
||||
let recursive = true;
|
||||
let full = false;
|
||||
let all = false;
|
||||
let confirmed = false;
|
||||
let confirmPath: string | undefined;
|
||||
const seen = new Set<string>();
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
const flags = new Set([
|
||||
"--root",
|
||||
"--source",
|
||||
"--target",
|
||||
"--confirm",
|
||||
"--no-recursive",
|
||||
"--full",
|
||||
"--all",
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
// The first token is the verb for commands that have one (`path add`,
|
||||
// `backup create`). It used to fall into the flag loop and be rejected as an
|
||||
// unknown flag, which made every one of these commands permanently
|
||||
// unparseable — `path add --root X` printed "参数无效" even though `help`
|
||||
// documented it. The tests missed this because they asserted
|
||||
// `parsed?.plan!` and null satisfies `?.` + `!`.
|
||||
const verb = args[0];
|
||||
const start = verb !== undefined && !verb.startsWith("--") ? 1 : 0;
|
||||
|
||||
for (let index = start; index < args.length; index += 1) {
|
||||
valueFlags: readonly string[],
|
||||
switchFlags: readonly string[],
|
||||
): ParsedFlags | null {
|
||||
const values = new Map<string, string>();
|
||||
const switches = new Set<string>();
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (!flags.has(arg)) return null;
|
||||
// A repeated flag is a malformed argv, not an idempotent spelling. The
|
||||
// other command parsers already apply this rule to --json as well.
|
||||
if (seen.has(arg)) return null;
|
||||
seen.add(arg);
|
||||
if (arg === "--yes") {
|
||||
confirmed = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--confirm") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
if (confirmPath !== undefined) return null;
|
||||
confirmPath = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--json") continue;
|
||||
if (arg === "--no-recursive") {
|
||||
recursive = false;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--full") {
|
||||
full = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--all") {
|
||||
all = true;
|
||||
if (switchFlags.includes(arg)) {
|
||||
if (switches.has(arg)) return null;
|
||||
switches.add(arg);
|
||||
continue;
|
||||
}
|
||||
if (!valueFlags.includes(arg) || values.has(arg)) return null;
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
if (seenValues.has(arg)) return null;
|
||||
seenValues.add(arg);
|
||||
values.set(arg, value);
|
||||
index += 1;
|
||||
if (arg === "--root") root = value;
|
||||
else if (arg === "--source") source = value;
|
||||
else target = value;
|
||||
}
|
||||
return { values, switches };
|
||||
}
|
||||
|
||||
if (name === "path") {
|
||||
if (confirmPath !== undefined) return null;
|
||||
if (target !== undefined) return null;
|
||||
if (root === undefined) return null;
|
||||
if (source === undefined && args.includes("--source")) return null;
|
||||
if (seen.has("--full") || seen.has("--all")) return null;
|
||||
if (args[0] !== "add" && args[0] !== "remove") return null;
|
||||
if (
|
||||
args[0] === "remove" &&
|
||||
(seen.has("--source") || seen.has("--no-recursive"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const SOURCE_ID = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
|
||||
/** `scan-roots add --root <path> [--source <id>] [--no-recursive]` and
|
||||
* `scan-roots remove --root <path>` (`list` is answered separately). */
|
||||
export function parseScanRootsCommand(
|
||||
args: readonly string[],
|
||||
): ScanRootsCommand | null {
|
||||
const verb = args[0];
|
||||
if (verb === "add") {
|
||||
const flags = parseFlags(
|
||||
args.slice(1),
|
||||
["--root", "--source"],
|
||||
["--no-recursive", "--yes", "--json"],
|
||||
);
|
||||
const root = flags?.values.get("--root");
|
||||
if (!flags || root === undefined) return null;
|
||||
const source = flags.values.get("--source");
|
||||
if (source !== undefined && !SOURCE_ID.test(source)) return null;
|
||||
return {
|
||||
plan:
|
||||
args[0] === "add"
|
||||
? planAddRoot(root, source, recursive)
|
||||
: planRemoveRoot(root),
|
||||
confirmed,
|
||||
kind: "add",
|
||||
root,
|
||||
...(source === undefined ? {} : { source }),
|
||||
recursive: !flags.switches.has("--no-recursive"),
|
||||
};
|
||||
}
|
||||
if (name === "scan") {
|
||||
if (confirmPath !== undefined) return null;
|
||||
if (target !== undefined) return null;
|
||||
if (seen.has("--no-recursive")) return null;
|
||||
if (args[0] !== undefined && !args[0].startsWith("--")) return null;
|
||||
// `--all` means "every source Runtime actually found", so naming one too
|
||||
// would make the command mean two different things.
|
||||
if (all && source !== undefined) return null;
|
||||
if (all) return { plan: planScanAll(full), confirmed };
|
||||
// A source is the thing the Runtime scan endpoint addresses. A bare root is
|
||||
// still accepted for planning (it means "all registered roots") but cannot
|
||||
// be applied, because there is no endpoint that scans a raw path — the CLI
|
||||
// must not pretend it can.
|
||||
if (source === undefined) return { plan: planScan(root, full), confirmed };
|
||||
return { plan: planScanSource(source, full), confirmed };
|
||||
if (verb === "remove") {
|
||||
const flags = parseFlags(args.slice(1), ["--root"], ["--yes", "--json"]);
|
||||
const root = flags?.values.get("--root");
|
||||
return flags && root !== undefined ? { kind: "remove", root } : null;
|
||||
}
|
||||
if (name === "backup") {
|
||||
const verb = args[0];
|
||||
if (verb !== "create" && verb !== "restore") return null;
|
||||
if (seen.has("--full") || seen.has("--all") || seen.has("--no-recursive")) {
|
||||
return null;
|
||||
}
|
||||
if (verb === "restore") {
|
||||
if (source === undefined) return null;
|
||||
if (target !== undefined) return null;
|
||||
return {
|
||||
plan: planRestore(source),
|
||||
confirmed,
|
||||
...(confirmPath === undefined ? {} : { confirmPath }),
|
||||
};
|
||||
}
|
||||
if (confirmPath !== undefined) return null;
|
||||
if (source !== undefined) return null;
|
||||
if (target === undefined) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `scan --source <id>`. A scan names a source: the Runtime has no endpoint
|
||||
* that scans a bare path, so a missing `--source` is a usage error rather
|
||||
* than a preview of something that can never run. */
|
||||
export function parseScanCommand(args: readonly string[]): ScanCommand | null {
|
||||
const flags = parseFlags(args, ["--source"], ["--all", "--yes", "--json"]);
|
||||
if (!flags) return null;
|
||||
const source = flags.values.get("--source");
|
||||
if (flags.switches.has("--all")) {
|
||||
return source === undefined ? { kind: "all" } : null;
|
||||
}
|
||||
return source !== undefined && SOURCE_ID.test(source)
|
||||
? { kind: "source", sourceId: source }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** `backup create --target <path>` and
|
||||
* `backup restore --source <path> [--yes]`. */
|
||||
export function parseBackupCommand(
|
||||
args: readonly string[],
|
||||
): BackupCommand | null {
|
||||
const verb = args[0];
|
||||
if (verb === "create") {
|
||||
const flags = parseFlags(args.slice(1), ["--target"], ["--yes", "--json"]);
|
||||
const target = flags?.values.get("--target");
|
||||
return flags && target !== undefined ? { verb: "create", target } : null;
|
||||
}
|
||||
if (verb === "restore") {
|
||||
const flags = parseFlags(
|
||||
args.slice(1),
|
||||
["--source", "--confirm"],
|
||||
["--yes", "--json"],
|
||||
);
|
||||
const source = flags?.values.get("--source");
|
||||
if (!flags || source === undefined) return null;
|
||||
const confirmPath = flags.values.get("--confirm");
|
||||
return {
|
||||
plan: planBackup(target),
|
||||
confirmed,
|
||||
verb: "restore",
|
||||
source,
|
||||
confirmed: flags.switches.has("--yes"),
|
||||
...(confirmPath === undefined ? {} : { confirmPath }),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether `--confirm <path>` names the same restore source.
|
||||
/** Whether a legacy `--confirm <path>` names the same restore source.
|
||||
*
|
||||
* The comparison is lexical on purpose: the CLI must not resolve symlinks or
|
||||
* touch the filesystem before Core has validated the managed source. A
|
||||
* different string is a different confirmation, so a typo or a path escape
|
||||
* can never be applied as a side effect of `--yes`.
|
||||
* different string is a different confirmation.
|
||||
*/
|
||||
export function restoreConfirmationMatches(
|
||||
source: string,
|
||||
confirmPath: string | undefined,
|
||||
): boolean {
|
||||
return confirmPath === source;
|
||||
return confirmPath === undefined || confirmPath === source;
|
||||
}
|
||||
|
||||
export interface WriteSubmitCommand {
|
||||
@@ -272,41 +177,33 @@ export interface WriteStatusCommand {
|
||||
|
||||
export type WriteCommand = WriteSubmitCommand | WriteStatusCommand;
|
||||
|
||||
/** Parse SDK-native `write submit|status`. */
|
||||
/** Parse `write submit ...` and `write status <request-id>` (or `--id`). */
|
||||
export function parseWriteCommand(
|
||||
args: readonly string[],
|
||||
): WriteCommand | null {
|
||||
if (args.filter((item) => item === "--json").length > 1) return null;
|
||||
const meaningful = args.filter((item) => item !== "--json");
|
||||
if (meaningful[0] === "status") {
|
||||
const status = parseTaskCommand(meaningful.slice(1));
|
||||
const rest = meaningful.slice(1);
|
||||
if (rest.length === 1 && !rest[0].startsWith("--")) {
|
||||
return { kind: "status", id: rest[0] };
|
||||
}
|
||||
const status = parseTaskCommand(rest);
|
||||
return status === null ? null : { kind: "status", id: status.id };
|
||||
}
|
||||
// `write --session ...` without `submit` stays accepted for older scripts.
|
||||
const input = meaningful[0] === "submit" ? meaningful.slice(1) : meaningful;
|
||||
let session: string | undefined;
|
||||
let source = "memhub-cli";
|
||||
let messagesFile: string | undefined;
|
||||
let requestId: string | undefined;
|
||||
let memoryAt: string | undefined;
|
||||
let priority: "live" | "normal" | undefined;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const arg = input[index];
|
||||
if (arg === "--live") {
|
||||
priority = "live";
|
||||
continue;
|
||||
}
|
||||
const value = input[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
index += 1;
|
||||
if (arg === "--session") session = value;
|
||||
else if (arg === "--source") source = value;
|
||||
else if (arg === "--messages-file") messagesFile = value;
|
||||
else if (arg === "--request-id") requestId = value;
|
||||
else if (arg === "--memory-at") memoryAt = value;
|
||||
else return null;
|
||||
}
|
||||
|
||||
const flags = parseFlags(
|
||||
input,
|
||||
["--session", "--source", "--messages-file", "--request-id", "--memory-at"],
|
||||
["--live"],
|
||||
);
|
||||
if (!flags) return null;
|
||||
const session = flags.values.get("--session");
|
||||
const source = flags.values.get("--source") ?? "memhub-cli";
|
||||
const messagesFile = flags.values.get("--messages-file");
|
||||
const requestId = flags.values.get("--request-id");
|
||||
const memoryAt = flags.values.get("--memory-at");
|
||||
if (session === undefined || messagesFile === undefined || !source)
|
||||
return null;
|
||||
if (
|
||||
@@ -323,39 +220,23 @@ export function parseWriteCommand(
|
||||
...(requestId === undefined ? {} : { request_id: requestId }),
|
||||
...(memoryAt === undefined ? {} : { memory_at: memoryAt }),
|
||||
};
|
||||
return priority === undefined ? command : { ...command, priority };
|
||||
return flags.switches.has("--live")
|
||||
? { ...command, priority: "live" }
|
||||
: command;
|
||||
}
|
||||
|
||||
export interface TaskCommand {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
/** `--id <request-id>`: the legacy `task` spelling of `write status`. */
|
||||
export function parseTaskCommand(args: readonly string[]): TaskCommand | null {
|
||||
if (args.filter((item) => item === "--json").length > 1) return null;
|
||||
let id: string | undefined;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--json") continue;
|
||||
if (arg !== "--id") return null;
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
index += 1;
|
||||
id = value;
|
||||
}
|
||||
const flags = parseFlags(
|
||||
args.filter((item) => item !== "--json"),
|
||||
["--id"],
|
||||
[],
|
||||
);
|
||||
const id = flags?.values.get("--id");
|
||||
return id === undefined ? null : { id };
|
||||
}
|
||||
|
||||
/** The human form. Kept separate from the JSON form on purpose. */
|
||||
export function describe(plan: Plan): string {
|
||||
const line = `${plan.action}: ${plan.target}`;
|
||||
return plan.detail ? `${line} (${plan.detail})` : line;
|
||||
}
|
||||
|
||||
/** The machine form: one document, nothing else on stdout. */
|
||||
export function asPlanDocument(plan: Plan): {
|
||||
schema_version: 1;
|
||||
plan: Plan;
|
||||
applied: boolean;
|
||||
} {
|
||||
return { schema_version: 1, plan, applied: false };
|
||||
}
|
||||
@@ -878,8 +878,11 @@ describe("CLI-21 memory reads", () => {
|
||||
layer: "L2_FACT",
|
||||
status: "active",
|
||||
});
|
||||
expect(report.evidence).toEqual([]);
|
||||
expect(report.history).toEqual([]);
|
||||
// Schema 2, like add/update/delete; the v1-era always-empty
|
||||
// `evidence`/`history` fields are gone.
|
||||
expect(report.schema_version).toBe(2);
|
||||
expect(report).not.toHaveProperty("evidence");
|
||||
expect(report).not.toHaveProperty("history");
|
||||
});
|
||||
|
||||
// Core registers /api/v2/memories/graph before /{memory_id}, and that static
|
||||
@@ -1137,14 +1140,16 @@ describe("backup create target refusal is rejected, not usage_error", () => {
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["backup", "create", "--target", "/tmp/outside.db", "--yes", "--json"],
|
||||
["backup", "create", "--target", "/tmp/outside.db", "--json"],
|
||||
{},
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
const document = JSON.parse(streams.stderr());
|
||||
// A result, on stdout like every --json document.
|
||||
expect(streams.stderr()).toBe("");
|
||||
const document = JSON.parse(streams.stdout());
|
||||
expect(document.status).not.toBe("usage_error");
|
||||
expect(document).toMatchObject({
|
||||
command: "backup",
|
||||
|
||||
@@ -341,7 +341,11 @@ function validated<T>(
|
||||
):
|
||||
| { state: "ok"; data: T }
|
||||
| { state: Exclude<ReadState["state"], "ok">; code: string } {
|
||||
if (outcome.state !== "ok") return outcome;
|
||||
if (outcome.state !== "ok") {
|
||||
// A Core refusal of the request itself reads as an invalid answer here.
|
||||
const state = outcome.state === "rejected" ? "invalid" : outcome.state;
|
||||
return { state, code: outcome.code };
|
||||
}
|
||||
const data = validate(outcome.data);
|
||||
return data === null
|
||||
? { state: "invalid", code: "response_invalid" }
|
||||
|
||||
@@ -102,6 +102,10 @@ export function uiSessionFailure(code: string): { code: string } {
|
||||
|
||||
export function uiExitCode(result: UiResult): number {
|
||||
if (result.state === "opened") return 0;
|
||||
// A stopped Runtime is the same exit 3 every other command reports.
|
||||
if (result.state === "blocked" && result.code === "runtime_not_running") {
|
||||
return 3;
|
||||
}
|
||||
if (result.state === "blocked") return 4;
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1,479 +0,0 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
vi.mock("axios", () => ({
|
||||
default: { create: vi.fn(() => transport), get: vi.fn() },
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
const EVENT = "11111111-1111-4111-8111-111111111111";
|
||||
const UPLOAD = "22222222-2222-4222-8222-222222222222";
|
||||
const NOTE = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// clearAllMocks keeps prior mock implementations, so a mockResolvedValue
|
||||
// from an earlier test would leak into the next one and fake its response.
|
||||
transport.get.mockReset();
|
||||
transport.post.mockReset();
|
||||
transport.put.mockReset();
|
||||
transport.patch.mockReset();
|
||||
transport.delete.mockReset();
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
instanceId: "instance-1",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
version: "1.2.3",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue({
|
||||
instanceId: "instance-1",
|
||||
apiToken: "synthetic-runtime-token",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
servicePid: 4242,
|
||||
health: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.2.3",
|
||||
instance_id: "instance-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("calls clear", () => {
|
||||
it("reports runtime_stopped (exit 3) without attempting a write", async () => {
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "stopped",
|
||||
code: "runtime_not_running",
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"calls",
|
||||
"clear",
|
||||
"--all",
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(3);
|
||||
expect(JSON.parse(result.stdout).code).toBe("runtime_not_running");
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews without contacting Core", async () => {
|
||||
const result = await capturedRun([
|
||||
"calls",
|
||||
"clear",
|
||||
"--event",
|
||||
EVENT,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.state).toBe("preview");
|
||||
expect(document.plan.kind).toBe("call_events_clear");
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
expect(transport.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the exact Core body with --confirm", async () => {
|
||||
transport.post.mockResolvedValue({ status: 200, data: { removed: 1 } });
|
||||
const result = await capturedRun([
|
||||
"calls",
|
||||
"clear",
|
||||
"--event",
|
||||
EVENT,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/call-events/clear",
|
||||
{ event_ids: [EVENT], user_confirmed: true },
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(result.stdout).state).toBe("committed");
|
||||
});
|
||||
|
||||
it("clears all without naming events", async () => {
|
||||
transport.post.mockResolvedValue({ status: 200, data: { removed: 4 } });
|
||||
const result = await capturedRun([
|
||||
"calls",
|
||||
"clear",
|
||||
"--all",
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/call-events/clear-all",
|
||||
{ user_confirmed: true },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed event id without contacting Core", async () => {
|
||||
const result = await capturedRun([
|
||||
"calls",
|
||||
"clear",
|
||||
"--event",
|
||||
"not-a-uuid",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(2);
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never reports committed for a malformed success body", async () => {
|
||||
transport.post.mockResolvedValue({ status: 200, data: { wrong: true } });
|
||||
const result = await capturedRun([
|
||||
"calls",
|
||||
"clear",
|
||||
"--event",
|
||||
EVENT,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.state).toBe("unknown");
|
||||
expect(document.code).toBe("ui_write_receipt_invalid");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip("retired import-file", () => {
|
||||
it("never uploads without --confirm", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-import-"));
|
||||
const file = path.join(directory, "export.json");
|
||||
await writeFile(file, '{"conversations":[]}', { mode: 0o600 });
|
||||
const result = await capturedRun([
|
||||
"import-file",
|
||||
"upload",
|
||||
"--file",
|
||||
file,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.state).toBe("preview");
|
||||
expect(document.plan.operation_id).toBeDefined();
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uploads the file with a deterministic operation id on confirm", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-import-"));
|
||||
const file = path.join(directory, "export.json");
|
||||
await writeFile(file, '{"conversations":[]}', { mode: 0o600 });
|
||||
transport.post.mockResolvedValue({
|
||||
status: 201,
|
||||
data: {
|
||||
schema_version: 1,
|
||||
operation_id: "ignored-here",
|
||||
state: "completed",
|
||||
upload_id: UPLOAD,
|
||||
upload_state: "staged",
|
||||
},
|
||||
});
|
||||
const preview = await capturedRun([
|
||||
"import-file",
|
||||
"upload",
|
||||
"--file",
|
||||
file,
|
||||
"--json",
|
||||
]);
|
||||
const plan = JSON.parse(preview.stdout).plan;
|
||||
transport.post.mockResolvedValue({
|
||||
status: 201,
|
||||
data: {
|
||||
schema_version: 1,
|
||||
operation_id: plan.operation_id,
|
||||
state: "completed",
|
||||
upload_id: UPLOAD,
|
||||
upload_state: "staged",
|
||||
},
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"import-file",
|
||||
"upload",
|
||||
"--file",
|
||||
file,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
const url = transport.post.mock.calls.at(-1)?.[0] as string;
|
||||
expect(url).toContain("/api/v1/import-files?");
|
||||
expect(url).toContain(`operation_id=${plan.operation_id}`);
|
||||
expect(url).toContain("filename=export.json");
|
||||
expect(JSON.parse(result.stdout).state).toBe("committed");
|
||||
});
|
||||
|
||||
it("commits and discards only with explicit confirmation", async () => {
|
||||
const preview = await capturedRun([
|
||||
"import-file",
|
||||
"commit",
|
||||
UPLOAD,
|
||||
"--json",
|
||||
]);
|
||||
expect(preview.code).toBe(0);
|
||||
expect(JSON.parse(preview.stdout).state).toBe("preview");
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
upload_id: UPLOAD,
|
||||
filename: "x.json",
|
||||
vendor: "generic",
|
||||
source_id: "generic-export",
|
||||
messages: 0,
|
||||
conversations: 0,
|
||||
human_messages: 0,
|
||||
agent_messages: 0,
|
||||
skipped_records: 0,
|
||||
ignored_files: 0,
|
||||
checksum: "a".repeat(64),
|
||||
created_at: "T",
|
||||
committed: true,
|
||||
},
|
||||
});
|
||||
const committed = await capturedRun([
|
||||
"import-file",
|
||||
"commit",
|
||||
UPLOAD,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(committed.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
`/api/v1/import-files/${UPLOAD}/commit`,
|
||||
{ confirmed: true },
|
||||
undefined,
|
||||
);
|
||||
|
||||
transport.delete.mockResolvedValue({ status: 204, data: "" });
|
||||
const discarded = await capturedRun([
|
||||
"import-file",
|
||||
"discard",
|
||||
UPLOAD,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(discarded.code).toBe(0);
|
||||
expect(JSON.parse(discarded.stdout).state).toBe("committed");
|
||||
expect(transport.delete).toHaveBeenCalledWith(
|
||||
`/api/v1/import-files/${UPLOAD}`,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps a Core 404 to a stable rejected code, not a fake success", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 404,
|
||||
data: { detail: "import_file_not_found" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"import-file",
|
||||
"commit",
|
||||
UPLOAD,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.state).toBe("rejected");
|
||||
expect(document.code).toBe("import_file_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notes / extraction / graph / manual extract", () => {
|
||||
it("adds a note from a bounded file and re-verifies content on apply", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-note-"));
|
||||
const content = path.join(directory, "note.txt");
|
||||
await writeFile(content, "记住这个决定", { mode: 0o600 });
|
||||
const preview = await capturedRun([
|
||||
"notes",
|
||||
"add",
|
||||
"--content-file",
|
||||
content,
|
||||
"--json",
|
||||
]);
|
||||
expect(preview.code).toBe(0);
|
||||
const planFile = path.join(directory, "plan.json");
|
||||
await writeFile(planFile, preview.stdout, { mode: 0o600 });
|
||||
transport.post.mockResolvedValue({
|
||||
status: 201,
|
||||
// A realistic Core `MemoryNoteReceipt`: the shared parser requires
|
||||
// `scope` and `memory_ids` too, so a minimal `{evidence_id,state}` stub
|
||||
// is a malformed body, not a success.
|
||||
data: {
|
||||
evidence_id: NOTE,
|
||||
text: "记住这个决定",
|
||||
scope: "all-apps",
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
state: "pending",
|
||||
task_id: null,
|
||||
memory_ids: [],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
const applied = await capturedRun([
|
||||
"notes",
|
||||
"apply",
|
||||
"--plan-file",
|
||||
planFile,
|
||||
"--content-file",
|
||||
content,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(applied.code).toBe(0);
|
||||
const body = transport.post.mock.calls.at(-1)?.[1] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body.text).toBe("记住这个决定");
|
||||
expect(body.scope).toBe("all-apps");
|
||||
expect(JSON.parse(applied.stdout).state).toBe("committed");
|
||||
});
|
||||
|
||||
it.skip("plans then applies the extraction window", async () => {
|
||||
const preview = await capturedRun([
|
||||
"extraction",
|
||||
"set",
|
||||
"--window",
|
||||
"6m",
|
||||
"--json",
|
||||
]);
|
||||
expect(preview.code).toBe(0);
|
||||
expect(JSON.parse(preview.stdout).state).toBe("preview");
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
transport.put.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { window_preset: "6m" },
|
||||
});
|
||||
const applied = await capturedRun([
|
||||
"extraction",
|
||||
"set",
|
||||
"--window",
|
||||
"6m",
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(applied.code).toBe(0);
|
||||
expect(transport.put).toHaveBeenCalledWith(
|
||||
"/api/v1/extraction-settings",
|
||||
{ window_preset: "6m" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call graph width without --confirm", async () => {
|
||||
const preview = await capturedRun([
|
||||
"graph",
|
||||
"width",
|
||||
"--decision",
|
||||
"rebuild_with_new",
|
||||
"--json",
|
||||
]);
|
||||
expect(preview.code).toBe(0);
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { archived_path: "/tmp/old", exported_path: null, mismatch: null },
|
||||
});
|
||||
const applied = await capturedRun([
|
||||
"graph",
|
||||
"width",
|
||||
"--decision",
|
||||
"rebuild_with_new",
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(applied.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/graph-width",
|
||||
{ decision: "rebuild_with_new" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it.skip("plans manual extraction and applies it only on confirm", async () => {
|
||||
const preview = await capturedRun([
|
||||
"processing",
|
||||
"extract",
|
||||
"--evidence",
|
||||
NOTE,
|
||||
"--json",
|
||||
]);
|
||||
expect(preview.code).toBe(0);
|
||||
expect(JSON.parse(preview.stdout).state).toBe("preview");
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { promoted: 1, batches_planned: 2, batches_current: 1 },
|
||||
});
|
||||
const applied = await capturedRun([
|
||||
"processing",
|
||||
"extract",
|
||||
"--evidence",
|
||||
NOTE,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]);
|
||||
expect(applied.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/memory-processing/manual-extract",
|
||||
{ evidence_ids: [NOTE] },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function capturedRun(arguments_: string[]) {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
return {
|
||||
code: await run(arguments_, {
|
||||
ONE_MEMORY_DATA_DIR: "/synthetic/data",
|
||||
}),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
/**
|
||||
* The remaining Web Console mutating operations the CLI did not expose:
|
||||
* 端内新增记忆 (memory-notes), extraction window, graph width, and manual
|
||||
* extraction.
|
||||
*
|
||||
* Core owns every rule; this module only validates the input it is about to
|
||||
* send, performs one explicit two-phase write, and normalises the receipt so
|
||||
* the CLI never reports a write as committed without evidence.
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
buildMemoryNoteSaveRequest,
|
||||
MemoryNoteInputError,
|
||||
parseExtractionSettingsReceipt,
|
||||
parseGraphWidthDecisionReceipt,
|
||||
parseManualExtractReceipt,
|
||||
parseMemoryNoteReceipt,
|
||||
} from "@one-memory/operations";
|
||||
import type { CoreOperations, CorePutOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const NOTE_MAX_CHARACTERS = 12_000;
|
||||
const EXTRACTION_PRESETS = ["3m", "6m", "1y", "all"] as const;
|
||||
const GRAPH_DECISIONS = [
|
||||
"keep_existing",
|
||||
"rebuild_with_new",
|
||||
"export_first",
|
||||
] as const;
|
||||
const NOTE_STATES = [
|
||||
"waiting_for_model",
|
||||
"waiting_for_permission",
|
||||
"pending",
|
||||
"running",
|
||||
"failed",
|
||||
"ready",
|
||||
"no_memories",
|
||||
] as const;
|
||||
|
||||
export type NoteState = (typeof NOTE_STATES)[number];
|
||||
|
||||
export type ExtendedWritePlan =
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "memory_note_add";
|
||||
request_id: string;
|
||||
content_sha256: string;
|
||||
content_characters: number;
|
||||
digest: string;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "memory_note_process";
|
||||
evidence_id: string;
|
||||
digest: string;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "extraction_window_set";
|
||||
window_preset: (typeof EXTRACTION_PRESETS)[number];
|
||||
digest: string;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "graph_width_decide";
|
||||
decision: (typeof GRAPH_DECISIONS)[number];
|
||||
digest: string;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "manual_extract";
|
||||
evidence_ids: string[];
|
||||
digest: string;
|
||||
};
|
||||
|
||||
export type ExtendedWriteOutcome =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "preview";
|
||||
plan: ExtendedWritePlan;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "committed" | "recovered";
|
||||
operation_id: string;
|
||||
receipt: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "rejected" | "unknown" | "invalid";
|
||||
operation_id?: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value !== null && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
if (value === undefined) return "null";
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function digestOf(value: unknown): string {
|
||||
return createHash("sha256").update(canonical(value)).digest("hex");
|
||||
}
|
||||
|
||||
type Draft =
|
||||
| Omit<Extract<ExtendedWritePlan, { kind: "memory_note_add" }>, "digest">
|
||||
| Omit<Extract<ExtendedWritePlan, { kind: "memory_note_process" }>, "digest">
|
||||
| Omit<
|
||||
Extract<ExtendedWritePlan, { kind: "extraction_window_set" }>,
|
||||
"digest"
|
||||
>
|
||||
| Omit<Extract<ExtendedWritePlan, { kind: "graph_width_decide" }>, "digest">
|
||||
| Omit<Extract<ExtendedWritePlan, { kind: "manual_extract" }>, "digest">;
|
||||
|
||||
function preview(plan: Draft): ExtendedWriteOutcome {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "preview",
|
||||
plan: { ...plan, digest: digestOf(plan) } as ExtendedWritePlan,
|
||||
};
|
||||
}
|
||||
|
||||
function invalid(code: string): ExtendedWriteOutcome {
|
||||
return { schema_version: 1, product: PRODUCT, state: "invalid", code };
|
||||
}
|
||||
|
||||
function committed(
|
||||
operationId: string,
|
||||
receipt: Record<string, unknown>,
|
||||
state: "committed" | "recovered" = "committed",
|
||||
): ExtendedWriteOutcome {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state,
|
||||
operation_id: operationId,
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
function rejected(
|
||||
operationId: string | undefined,
|
||||
code: string,
|
||||
): ExtendedWriteOutcome {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "rejected",
|
||||
...(operationId === undefined ? {} : { operation_id: operationId }),
|
||||
code,
|
||||
};
|
||||
}
|
||||
|
||||
function unknown(
|
||||
operationId: string | undefined,
|
||||
code: string,
|
||||
): ExtendedWriteOutcome {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "unknown",
|
||||
...(operationId === undefined ? {} : { operation_id: operationId }),
|
||||
code,
|
||||
};
|
||||
}
|
||||
|
||||
// --- memory-notes ---------------------------------------------------------
|
||||
|
||||
export function planMemoryNoteAdd(text: string): ExtendedWriteOutcome {
|
||||
// The plan keeps only the note's identity, like `memories plan add`: the
|
||||
//正文 lives in a bounded file and is re-read and re-hashed at apply time, so
|
||||
// a plan file never carries the user's private text.
|
||||
if (!validNoteText(text)) return invalid("memory_note_text_invalid");
|
||||
const requestId = randomUUID();
|
||||
return preview({
|
||||
schema_version: 1,
|
||||
kind: "memory_note_add",
|
||||
request_id: requestId,
|
||||
content_sha256: createHash("sha256").update(text, "utf8").digest("hex"),
|
||||
content_characters: [...text].length,
|
||||
});
|
||||
}
|
||||
|
||||
export function validNoteText(text: string): boolean {
|
||||
return (
|
||||
text.length > 0 &&
|
||||
[...text].length <= NOTE_MAX_CHARACTERS &&
|
||||
text.trim().length > 0 &&
|
||||
![...text].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 8 || (code >= 11 && code <= 31) || code === 127;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function planMemoryNoteProcess(
|
||||
evidenceId: string,
|
||||
): ExtendedWriteOutcome {
|
||||
return UUID.test(evidenceId)
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
kind: "memory_note_process",
|
||||
evidence_id: evidenceId,
|
||||
})
|
||||
: invalid("memory_note_id_invalid");
|
||||
}
|
||||
|
||||
function noteReceipt(
|
||||
operationId: string,
|
||||
data: { evidence_id: string; state: NoteState },
|
||||
): ExtendedWriteOutcome {
|
||||
return committed(operationId, { ...data });
|
||||
}
|
||||
|
||||
export async function executeMemoryNoteAdd(
|
||||
plan: Extract<ExtendedWritePlan, { kind: "memory_note_add" }>,
|
||||
operations: CoreOperations,
|
||||
text: string,
|
||||
): Promise<ExtendedWriteOutcome> {
|
||||
if (
|
||||
!validNoteText(text) ||
|
||||
createHash("sha256").update(text, "utf8").digest("hex") !==
|
||||
plan.content_sha256 ||
|
||||
[...text].length !== plan.content_characters
|
||||
) {
|
||||
return invalid("memory_note_content_mismatch");
|
||||
}
|
||||
// The shared builder owns the save body (request_id/text/scope=all-apps), so
|
||||
// the CLI and the Web console post the same three fields. Its own text check
|
||||
// already ran above, so a throw here means an unusable plan.
|
||||
let request: ReturnType<typeof buildMemoryNoteSaveRequest>;
|
||||
try {
|
||||
request = buildMemoryNoteSaveRequest({
|
||||
request_id: plan.request_id,
|
||||
text,
|
||||
scope: "all-apps",
|
||||
} as Parameters<typeof buildMemoryNoteSaveRequest>[0]);
|
||||
} catch (error) {
|
||||
if (error instanceof MemoryNoteInputError) {
|
||||
return invalid("memory_note_content_mismatch");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await operations.post<unknown>(request.path, request.body);
|
||||
if (result.state === "rejected") {
|
||||
return rejected(plan.request_id, result.code);
|
||||
}
|
||||
if (result.state === "unknown") {
|
||||
return unknown(plan.request_id, result.code);
|
||||
}
|
||||
const receipt = asNoteReceipt(result.data);
|
||||
return receipt === null
|
||||
? unknown(plan.request_id, "ui_write_receipt_invalid")
|
||||
: noteReceipt(plan.request_id, receipt);
|
||||
}
|
||||
|
||||
export async function executeMemoryNoteProcess(
|
||||
plan: Extract<ExtendedWritePlan, { kind: "memory_note_process" }>,
|
||||
operations: CoreOperations,
|
||||
): Promise<ExtendedWriteOutcome> {
|
||||
const result = await operations.post<unknown>(
|
||||
`/api/v1/memory-notes/${plan.evidence_id}/process`,
|
||||
{},
|
||||
);
|
||||
if (result.state === "rejected") {
|
||||
return rejected(plan.evidence_id, result.code);
|
||||
}
|
||||
if (result.state === "unknown") {
|
||||
return unknown(plan.evidence_id, result.code);
|
||||
}
|
||||
const receipt = asNoteReceipt(result.data);
|
||||
return receipt === null
|
||||
? unknown(plan.evidence_id, "ui_write_receipt_invalid")
|
||||
: noteReceipt(plan.evidence_id, receipt);
|
||||
}
|
||||
|
||||
function asNoteReceipt(
|
||||
value: unknown,
|
||||
): { evidence_id: string; state: NoteState } | null {
|
||||
// The shared parser is the single judge of a note receipt; a malformed body
|
||||
// returns null and the caller reports `unknown`, never a saved note.
|
||||
const receipt = parseMemoryNoteReceipt(value);
|
||||
return receipt === null
|
||||
? null
|
||||
: { evidence_id: receipt.evidence_id, state: receipt.state as NoteState };
|
||||
}
|
||||
|
||||
// --- extraction window / graph width / manual extract ---------------------
|
||||
|
||||
export function planExtractionWindowSet(
|
||||
windowPreset: string,
|
||||
): ExtendedWriteOutcome {
|
||||
return (EXTRACTION_PRESETS as readonly string[]).includes(windowPreset)
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
kind: "extraction_window_set",
|
||||
window_preset: windowPreset as (typeof EXTRACTION_PRESETS)[number],
|
||||
})
|
||||
: invalid("extraction_window_preset_invalid");
|
||||
}
|
||||
|
||||
export function planGraphWidthDecide(decision: string): ExtendedWriteOutcome {
|
||||
return (GRAPH_DECISIONS as readonly string[]).includes(decision)
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
kind: "graph_width_decide",
|
||||
decision: decision as (typeof GRAPH_DECISIONS)[number],
|
||||
})
|
||||
: invalid("graph_width_decision_invalid");
|
||||
}
|
||||
|
||||
export function planManualExtract(evidenceIds: string[]): ExtendedWriteOutcome {
|
||||
const ids = [...new Set(evidenceIds)];
|
||||
return ids.length === evidenceIds.length && ids.every((id) => UUID.test(id))
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
kind: "manual_extract",
|
||||
evidence_ids: ids,
|
||||
})
|
||||
: invalid("manual_extract_evidence_ids_invalid");
|
||||
}
|
||||
|
||||
export async function executeExtractionWindowSet(
|
||||
plan: Extract<ExtendedWritePlan, { kind: "extraction_window_set" }>,
|
||||
operations: CorePutOperations,
|
||||
): Promise<ExtendedWriteOutcome> {
|
||||
const result = await operations.put<unknown>("/api/v1/extraction-settings", {
|
||||
window_preset: plan.window_preset,
|
||||
});
|
||||
if (result.state === "rejected") {
|
||||
return rejected(plan.window_preset, result.code);
|
||||
}
|
||||
if (result.state === "unknown") {
|
||||
return unknown(plan.window_preset, result.code);
|
||||
}
|
||||
// The shared parser also enforces that the receipt echoes the requested
|
||||
// preset, so a response belonging to another write cannot pass.
|
||||
const parsed = parseExtractionSettingsReceipt(
|
||||
result.data,
|
||||
plan.window_preset,
|
||||
);
|
||||
if (parsed === null) {
|
||||
return unknown(plan.window_preset, "ui_write_receipt_invalid");
|
||||
}
|
||||
return committed(plan.window_preset, { window_preset: plan.window_preset });
|
||||
}
|
||||
|
||||
export async function executeGraphWidthDecide(
|
||||
plan: Extract<ExtendedWritePlan, { kind: "graph_width_decide" }>,
|
||||
operations: CoreOperations,
|
||||
): Promise<ExtendedWriteOutcome> {
|
||||
const result = await operations.post<unknown>("/api/v1/graph-width", {
|
||||
decision: plan.decision,
|
||||
});
|
||||
if (result.state === "rejected") {
|
||||
return rejected(plan.decision, result.code);
|
||||
}
|
||||
if (result.state === "unknown") {
|
||||
return unknown(plan.decision, result.code);
|
||||
}
|
||||
const receipt = parseGraphWidthDecisionReceipt(result.data);
|
||||
if (receipt === null) {
|
||||
return unknown(plan.decision, "ui_write_receipt_invalid");
|
||||
}
|
||||
return committed(plan.decision, {
|
||||
archived_path: receipt.archived_path ?? null,
|
||||
exported_path: receipt.exported_path ?? null,
|
||||
mismatch: receipt.mismatch ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeManualExtract(
|
||||
plan: Extract<ExtendedWritePlan, { kind: "manual_extract" }>,
|
||||
operations: CoreOperations,
|
||||
): Promise<ExtendedWriteOutcome> {
|
||||
const result = await operations.post<unknown>(
|
||||
"/api/v1/memory-processing/manual-extract",
|
||||
{ evidence_ids: plan.evidence_ids },
|
||||
);
|
||||
if (result.state === "rejected") {
|
||||
return rejected("manual_extract", result.code);
|
||||
}
|
||||
if (result.state === "unknown") {
|
||||
return unknown("manual_extract", result.code);
|
||||
}
|
||||
const receipt = parseManualExtractReceipt(result.data);
|
||||
if (receipt === null) {
|
||||
return unknown("manual_extract", "ui_write_receipt_invalid");
|
||||
}
|
||||
return committed("manual_extract", {
|
||||
promoted: receipt.promoted,
|
||||
batches_planned: receipt.batches_planned,
|
||||
batches_current: receipt.batches_current,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateExtendedWritePlan(
|
||||
value: unknown,
|
||||
): ExtendedWritePlan | null {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.schema_version !== 1 || typeof candidate.kind !== "string") {
|
||||
return null;
|
||||
}
|
||||
const { digest, ...withoutDigest } = candidate;
|
||||
if (typeof digest !== "string" || digest !== digestOf(withoutDigest)) {
|
||||
return null;
|
||||
}
|
||||
switch (candidate.kind) {
|
||||
case "memory_note_add":
|
||||
return typeof candidate.request_id === "string" &&
|
||||
UUID.test(candidate.request_id) &&
|
||||
typeof candidate.content_sha256 === "string" &&
|
||||
DIGEST.test(candidate.content_sha256) &&
|
||||
Number.isSafeInteger(candidate.content_characters) &&
|
||||
(candidate.content_characters as number) >= 1 &&
|
||||
(candidate.content_characters as number) <= NOTE_MAX_CHARACTERS &&
|
||||
Object.keys(candidate).length === 6
|
||||
? (candidate as unknown as ExtendedWritePlan)
|
||||
: null;
|
||||
case "memory_note_process":
|
||||
return typeof candidate.evidence_id === "string" &&
|
||||
UUID.test(candidate.evidence_id) &&
|
||||
Object.keys(candidate).length === 4
|
||||
? (candidate as unknown as ExtendedWritePlan)
|
||||
: null;
|
||||
case "extraction_window_set":
|
||||
return (EXTRACTION_PRESETS as readonly string[]).includes(
|
||||
String(candidate.window_preset),
|
||||
) && Object.keys(candidate).length === 4
|
||||
? (candidate as unknown as ExtendedWritePlan)
|
||||
: null;
|
||||
case "graph_width_decide":
|
||||
return (GRAPH_DECISIONS as readonly string[]).includes(
|
||||
String(candidate.decision),
|
||||
) && Object.keys(candidate).length === 4
|
||||
? (candidate as unknown as ExtendedWritePlan)
|
||||
: null;
|
||||
case "manual_extract":
|
||||
return Array.isArray(candidate.evidence_ids) &&
|
||||
candidate.evidence_ids.every(
|
||||
(id) => typeof id === "string" && UUID.test(id),
|
||||
) &&
|
||||
Object.keys(candidate).length === 4
|
||||
? (candidate as unknown as ExtendedWritePlan)
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extendedWriteExitCode(document: ExtendedWriteOutcome): number {
|
||||
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderExtendedWriteText(
|
||||
document: ExtendedWriteOutcome,
|
||||
): string {
|
||||
if (document.state === "preview") {
|
||||
const lines = ["以下操作尚未执行;加 --confirm 后才会写入:"];
|
||||
const plan = document.plan;
|
||||
switch (plan.kind) {
|
||||
case "memory_note_add":
|
||||
lines.push(`新增端内记忆:${plan.content_characters} 字符`);
|
||||
break;
|
||||
case "memory_note_process":
|
||||
lines.push(`处理端内记忆:${plan.evidence_id}`);
|
||||
break;
|
||||
case "extraction_window_set":
|
||||
lines.push(`设置提取窗口:${plan.window_preset}`);
|
||||
break;
|
||||
case "graph_width_decide":
|
||||
lines.push(`图谱宽度决定:${plan.decision}`);
|
||||
break;
|
||||
case "manual_extract":
|
||||
lines.push(`手动提取:${plan.evidence_ids.length} 条证据`);
|
||||
break;
|
||||
}
|
||||
lines.push(`plan_digest:${plan.digest}`);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
if (document.state === "committed" || document.state === "recovered") {
|
||||
return `${document.state === "recovered" ? "已从回执恢复" : "已提交"}:${document.operation_id}\n${JSON.stringify(document.receipt)}\n`;
|
||||
}
|
||||
const code = "code" in document ? document.code : "ui_write_state_invalid";
|
||||
return `操作未完成:${document.state}(${code})\n`;
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
CoreDeleteOperations,
|
||||
CoreOperations,
|
||||
} from "./core-operations.js";
|
||||
import {
|
||||
executeCallEventsClear,
|
||||
executeCallEventsClearAll,
|
||||
executeImportFileCommit,
|
||||
executeImportFileDiscard,
|
||||
planCallEventsClear,
|
||||
planCallEventsClearAll,
|
||||
planImportFileCommit,
|
||||
planImportFileDiscard,
|
||||
planImportFileUpload,
|
||||
validateUiWritePlan,
|
||||
} from "./ui-write-flows.js";
|
||||
|
||||
const EVENT = "11111111-1111-4111-8111-111111111111";
|
||||
const UPLOAD = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
function planOf(value: ReturnType<typeof planCallEventsClear>) {
|
||||
if (value.state !== "preview") throw new Error("expected preview");
|
||||
return value.plan;
|
||||
}
|
||||
|
||||
describe("UI write plans", () => {
|
||||
it("permits only valid call-event ids and rejects duplicates", () => {
|
||||
expect(planCallEventsClear([EVENT]).state).toBe("preview");
|
||||
expect(planCallEventsClear([]).state).toBe("invalid");
|
||||
expect(planCallEventsClear([EVENT, EVENT]).state).toBe("invalid");
|
||||
});
|
||||
|
||||
it("plans all remaining UI write operations", () => {
|
||||
expect(planCallEventsClearAll().state).toBe("preview");
|
||||
expect(planImportFileCommit(UPLOAD).state).toBe("preview");
|
||||
expect(planImportFileDiscard(UPLOAD).state).toBe("preview");
|
||||
expect(
|
||||
planImportFileUpload({
|
||||
path: "/tmp/export.json",
|
||||
bytes: 42,
|
||||
sha256: "a".repeat(64),
|
||||
inputKind: "raw_conversation",
|
||||
}).state,
|
||||
).toBe("preview");
|
||||
});
|
||||
|
||||
it("rejects a tampered plan digest", async () => {
|
||||
const plan = planOf(planCallEventsClear([EVENT]));
|
||||
expect(validateUiWritePlan(plan)).toEqual(plan);
|
||||
expect(validateUiWritePlan({ ...plan, event_ids: [UPLOAD] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UI write execution", () => {
|
||||
it("posts clear with Core's confirmation field", async () => {
|
||||
const post = vi.fn(async () => ({
|
||||
state: "committed" as const,
|
||||
data: { removed: 1 },
|
||||
}));
|
||||
const plan = planOf(planCallEventsClear([EVENT]));
|
||||
if (plan.kind !== "call_events_clear") throw new Error("kind");
|
||||
const result = await executeCallEventsClear(plan, {
|
||||
post,
|
||||
} as unknown as CoreOperations);
|
||||
expect(result.state).toBe("committed");
|
||||
expect(post).toHaveBeenCalledWith("/api/v1/call-events/clear", {
|
||||
event_ids: [EVENT],
|
||||
user_confirmed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("posts clear-all with Core's confirmation field", async () => {
|
||||
const post = vi.fn(async () => ({
|
||||
state: "committed" as const,
|
||||
data: { removed: 2 },
|
||||
}));
|
||||
const plan = planOf(planCallEventsClearAll());
|
||||
if (plan.kind !== "call_events_clear_all") throw new Error("kind");
|
||||
await executeCallEventsClearAll(plan, {
|
||||
post,
|
||||
} as unknown as CoreOperations);
|
||||
expect(post).toHaveBeenCalledWith("/api/v1/call-events/clear-all", {
|
||||
user_confirmed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses POST commit and DELETE discard endpoints", async () => {
|
||||
const post = vi.fn(async () => ({
|
||||
state: "committed" as const,
|
||||
data: {
|
||||
upload_id: UPLOAD,
|
||||
filename: "x.json",
|
||||
vendor: "generic",
|
||||
source_id: "generic-export",
|
||||
messages: 0,
|
||||
conversations: 0,
|
||||
human_messages: 0,
|
||||
agent_messages: 0,
|
||||
skipped_records: 0,
|
||||
ignored_files: 0,
|
||||
checksum: "a".repeat(64),
|
||||
created_at: "T",
|
||||
committed: true,
|
||||
},
|
||||
}));
|
||||
const del = vi.fn(async <T>() => ({
|
||||
state: "committed" as const,
|
||||
data: {} as T,
|
||||
}));
|
||||
const operations = {
|
||||
post,
|
||||
delete: del,
|
||||
} as unknown as CoreOperations & CoreDeleteOperations;
|
||||
const commit = planOf(planImportFileCommit(UPLOAD));
|
||||
if (commit.kind !== "import_file_commit") throw new Error("kind");
|
||||
await executeImportFileCommit(commit, operations);
|
||||
expect(post).toHaveBeenCalledWith(`/api/v1/import-files/${UPLOAD}/commit`, {
|
||||
confirmed: true,
|
||||
});
|
||||
const discard = planOf(planImportFileDiscard(UPLOAD));
|
||||
if (discard.kind !== "import_file_discard") throw new Error("kind");
|
||||
expect((await executeImportFileDiscard(discard, operations)).state).toBe(
|
||||
"committed",
|
||||
);
|
||||
expect(del).toHaveBeenCalledWith(`/api/v1/import-files/${UPLOAD}`);
|
||||
});
|
||||
|
||||
it("treats a malformed success receipt as unknown", async () => {
|
||||
const plan = planOf(planCallEventsClear([EVENT]));
|
||||
if (plan.kind !== "call_events_clear") throw new Error("kind");
|
||||
const result = await executeCallEventsClear(plan, {
|
||||
post: vi.fn(async () => ({
|
||||
state: "committed" as const,
|
||||
data: { wrong: true },
|
||||
})),
|
||||
} as unknown as CoreOperations);
|
||||
expect(result).toMatchObject({
|
||||
state: "unknown",
|
||||
code: "ui_write_receipt_invalid",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,555 +0,0 @@
|
||||
/**
|
||||
* The Web Console's remaining mutating operations, exposed to the CLI without
|
||||
* duplicating Core semantics. Each operation is planned first; callers apply
|
||||
* the exact plan they reviewed.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { type FileHandle, open } from "node:fs/promises";
|
||||
import type {
|
||||
CoreDeleteOperations,
|
||||
CoreOperations,
|
||||
CoreUploadOperations,
|
||||
} from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const INPUT_KINDS = ["raw_conversation", "memory_content"] as const;
|
||||
const MAX_UPLOAD_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
export type UiWritePlan =
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "call_events_clear";
|
||||
event_ids: string[];
|
||||
digest: string;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "call_events_clear_all";
|
||||
digest: string;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "import_file_commit";
|
||||
upload_id: string;
|
||||
digest: string;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "import_file_discard";
|
||||
upload_id: string;
|
||||
digest: string;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
kind: "import_file_upload";
|
||||
operation_id: string;
|
||||
path: string;
|
||||
filename: string;
|
||||
input_kind: (typeof INPUT_KINDS)[number];
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
digest: string;
|
||||
};
|
||||
|
||||
export type UiWriteOutcome =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "preview";
|
||||
plan: UiWritePlan;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "committed" | "recovered";
|
||||
operation_id: string;
|
||||
receipt: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "rejected" | "unknown" | "invalid";
|
||||
operation_id?: string;
|
||||
code: string;
|
||||
receipt?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function digestOf(value: unknown): string {
|
||||
return createHash("sha256").update(canonical(value)).digest("hex");
|
||||
}
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value !== null && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
if (value === undefined) return "null";
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export type UiWritePlanDraft =
|
||||
| Omit<Extract<UiWritePlan, { kind: "call_events_clear" }>, "digest">
|
||||
| Omit<Extract<UiWritePlan, { kind: "call_events_clear_all" }>, "digest">
|
||||
| Omit<Extract<UiWritePlan, { kind: "import_file_commit" }>, "digest">
|
||||
| Omit<Extract<UiWritePlan, { kind: "import_file_discard" }>, "digest">
|
||||
| Omit<Extract<UiWritePlan, { kind: "import_file_upload" }>, "digest">;
|
||||
|
||||
function preview(plan: UiWritePlanDraft): UiWriteOutcome {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "preview",
|
||||
plan: { ...plan, digest: digestOf(plan) } as UiWritePlan,
|
||||
};
|
||||
}
|
||||
|
||||
function invalid(code: string): UiWriteOutcome {
|
||||
return { schema_version: 1, product: PRODUCT, state: "invalid", code };
|
||||
}
|
||||
|
||||
function committed(
|
||||
operationId: string,
|
||||
receipt: Record<string, unknown>,
|
||||
state: "committed" | "recovered" = "committed",
|
||||
): UiWriteOutcome {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state,
|
||||
operation_id: operationId,
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
export function planCallEventsClear(
|
||||
eventIds: readonly string[],
|
||||
): UiWriteOutcome {
|
||||
if (
|
||||
eventIds.length < 1 ||
|
||||
eventIds.length > 100 ||
|
||||
new Set(eventIds).size !== eventIds.length ||
|
||||
eventIds.some((id) => !UUID.test(id))
|
||||
) {
|
||||
return invalid("call_event_ids_invalid");
|
||||
}
|
||||
return preview({
|
||||
schema_version: 1,
|
||||
kind: "call_events_clear",
|
||||
event_ids: [...eventIds],
|
||||
});
|
||||
}
|
||||
|
||||
export function planCallEventsClearAll(): UiWriteOutcome {
|
||||
return preview({ schema_version: 1, kind: "call_events_clear_all" });
|
||||
}
|
||||
|
||||
export function planImportFileCommit(uploadId: string): UiWriteOutcome {
|
||||
return UUID.test(uploadId)
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
kind: "import_file_commit",
|
||||
upload_id: uploadId,
|
||||
})
|
||||
: invalid("import_file_id_invalid");
|
||||
}
|
||||
|
||||
export function planImportFileDiscard(uploadId: string): UiWriteOutcome {
|
||||
return UUID.test(uploadId)
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
kind: "import_file_discard",
|
||||
upload_id: uploadId,
|
||||
})
|
||||
: invalid("import_file_id_invalid");
|
||||
}
|
||||
|
||||
export function planImportFileUpload(input: {
|
||||
path: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
inputKind: "raw_conversation" | "memory_content";
|
||||
}): UiWriteOutcome {
|
||||
const filename = input.path.split(/[/\\]/).pop() ?? "";
|
||||
if (
|
||||
input.path.length === 0 ||
|
||||
!Number.isSafeInteger(input.bytes) ||
|
||||
input.bytes < 0 ||
|
||||
input.bytes > MAX_UPLOAD_BYTES ||
|
||||
!/^[0-9a-f]{64}$/.test(input.sha256) ||
|
||||
!INPUT_KINDS.includes(input.inputKind) ||
|
||||
filename.length === 0 ||
|
||||
[...filename].length > 200 ||
|
||||
!/\.(?:zip|json|md|txt)$/i.test(filename)
|
||||
) {
|
||||
return invalid("import_file_upload_invalid");
|
||||
}
|
||||
return preview({
|
||||
schema_version: 1,
|
||||
kind: "import_file_upload",
|
||||
operation_id: fileUploadOperationId(
|
||||
filename,
|
||||
input.inputKind,
|
||||
input.sha256,
|
||||
),
|
||||
path: input.path,
|
||||
filename,
|
||||
input_kind: input.inputKind,
|
||||
bytes: input.bytes,
|
||||
sha256: input.sha256,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one regular, non-symlinked export without buffering past the 128 MiB
|
||||
* limit. Returns the bytes plus the checksum the plan records, so apply can
|
||||
* prove the file it uploads is the one the caller reviewed.
|
||||
*/
|
||||
export async function readImportFileUpload(
|
||||
filePath: string,
|
||||
inputKind: "raw_conversation" | "memory_content",
|
||||
): Promise<{ plan: UiWritePlan; bytes: Buffer } | null> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await open(
|
||||
filePath,
|
||||
constants.O_RDONLY |
|
||||
(constants.O_NONBLOCK ?? 0) |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile() || stats.nlink !== 1 || stats.size > MAX_UPLOAD_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.alloc(Number(stats.size));
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const chunk = await handle.read(
|
||||
buffer,
|
||||
offset,
|
||||
buffer.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (chunk.bytesRead === 0) break;
|
||||
offset += chunk.bytesRead;
|
||||
}
|
||||
if (offset !== buffer.length) return null;
|
||||
const checksum = createHash("sha256").update(buffer).digest("hex");
|
||||
const result = planImportFileUpload({
|
||||
path: filePath,
|
||||
bytes: buffer.byteLength,
|
||||
sha256: checksum,
|
||||
inputKind,
|
||||
});
|
||||
if (result.state !== "preview") return null;
|
||||
return { plan: result.plan, bytes: buffer };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core keys upload idempotency by `operation_id` and rejects a replay whose
|
||||
* fingerprint differs. Deriving it from the file's own identity means a retry
|
||||
* after a dropped response resumes the same staging operation instead of
|
||||
* creating a second upload; a genuinely different payload maps to a different
|
||||
* operation and cannot overwrite the first.
|
||||
*/
|
||||
function fileUploadOperationId(
|
||||
filename: string,
|
||||
inputKind: (typeof INPUT_KINDS)[number],
|
||||
checksum: string,
|
||||
): string {
|
||||
const hex = createHash("sha256")
|
||||
.update(JSON.stringify([filename, inputKind, checksum]))
|
||||
.digest("hex");
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
`4${hex.slice(13, 16)}`,
|
||||
`${(8 + (parseInt(hex[16]!, 16) % 4)).toString(16)}${hex.slice(17, 20)}`,
|
||||
hex.slice(20, 32),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
export function validateUiWritePlan(value: unknown): UiWritePlan | null {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.schema_version !== 1 || typeof candidate.kind !== "string") {
|
||||
return null;
|
||||
}
|
||||
const { digest, ...withoutDigest } = candidate;
|
||||
if (typeof digest !== "string" || digest !== digestOf(withoutDigest)) {
|
||||
return null;
|
||||
}
|
||||
switch (candidate.kind) {
|
||||
case "call_events_clear":
|
||||
return Array.isArray(candidate.event_ids) &&
|
||||
candidate.event_ids.length >= 1 &&
|
||||
candidate.event_ids.length <= 100 &&
|
||||
new Set(candidate.event_ids).size === candidate.event_ids.length &&
|
||||
candidate.event_ids.every(
|
||||
(id) => typeof id === "string" && UUID.test(id),
|
||||
)
|
||||
? (candidate as unknown as UiWritePlan)
|
||||
: null;
|
||||
case "call_events_clear_all":
|
||||
return Object.keys(candidate).length === 3
|
||||
? (candidate as unknown as UiWritePlan)
|
||||
: null;
|
||||
case "import_file_commit":
|
||||
case "import_file_discard":
|
||||
return typeof candidate.upload_id === "string" &&
|
||||
UUID.test(candidate.upload_id) &&
|
||||
Object.keys(candidate).length === 4
|
||||
? (candidate as unknown as UiWritePlan)
|
||||
: null;
|
||||
case "import_file_upload":
|
||||
return typeof candidate.operation_id === "string" &&
|
||||
UUID.test(candidate.operation_id) &&
|
||||
typeof candidate.path === "string" &&
|
||||
typeof candidate.filename === "string" &&
|
||||
candidate.filename === candidate.path.split(/[/\\]/).pop() &&
|
||||
/^[0-9a-f]{64}$/.test(String(candidate.sha256)) &&
|
||||
INPUT_KINDS.includes(
|
||||
candidate.input_kind as (typeof INPUT_KINDS)[number],
|
||||
) &&
|
||||
Number.isSafeInteger(candidate.bytes) &&
|
||||
(candidate.bytes as number) >= 0 &&
|
||||
(candidate.bytes as number) <= 128 * 1024 * 1024
|
||||
? (candidate as unknown as UiWritePlan)
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function uiWriteExitCode(document: UiWriteOutcome): number {
|
||||
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderUiWriteText(document: UiWriteOutcome): string {
|
||||
if (document.state === "preview") {
|
||||
return `以下操作尚未执行;加 --confirm 后才会写入:\n${document.plan.kind}\nplan_digest:${document.plan.digest}\n`;
|
||||
}
|
||||
if (document.state === "committed" || document.state === "recovered") {
|
||||
return `已提交:${document.operation_id}\n${JSON.stringify(document.receipt)}\n`;
|
||||
}
|
||||
const code = "code" in document ? document.code : "ui_write_state_invalid";
|
||||
return `操作未完成:${document.state}(${code})\n`;
|
||||
}
|
||||
|
||||
export async function executeCallEventsClear(
|
||||
plan: Extract<UiWritePlan, { kind: "call_events_clear" }>,
|
||||
operations: CoreOperations,
|
||||
): Promise<UiWriteOutcome> {
|
||||
const result = await operations.post<unknown>("/api/v1/call-events/clear", {
|
||||
event_ids: plan.event_ids,
|
||||
user_confirmed: true,
|
||||
});
|
||||
return outcome(result, plan.event_ids.join(","), ["removed"]);
|
||||
}
|
||||
|
||||
export async function executeCallEventsClearAll(
|
||||
plan: Extract<UiWritePlan, { kind: "call_events_clear_all" }>,
|
||||
operations: CoreOperations,
|
||||
): Promise<UiWriteOutcome> {
|
||||
const result = await operations.post<unknown>(
|
||||
"/api/v1/call-events/clear-all",
|
||||
{ user_confirmed: true },
|
||||
);
|
||||
return outcome(result, "all", ["removed"]);
|
||||
}
|
||||
|
||||
export async function executeImportFileCommit(
|
||||
plan: Extract<UiWritePlan, { kind: "import_file_commit" }>,
|
||||
operations: CoreOperations,
|
||||
): Promise<UiWriteOutcome> {
|
||||
const result = await operations.post<unknown>(
|
||||
`/api/v1/import-files/${plan.upload_id}/commit`,
|
||||
{ confirmed: true },
|
||||
);
|
||||
return outcome(result, plan.upload_id, [
|
||||
"upload_id",
|
||||
"filename",
|
||||
"vendor",
|
||||
"source_id",
|
||||
"messages",
|
||||
"conversations",
|
||||
"human_messages",
|
||||
"agent_messages",
|
||||
"skipped_records",
|
||||
"ignored_files",
|
||||
"checksum",
|
||||
"created_at",
|
||||
"committed",
|
||||
]);
|
||||
}
|
||||
|
||||
export async function executeImportFileDiscard(
|
||||
plan: Extract<UiWritePlan, { kind: "import_file_discard" }>,
|
||||
operations: CoreDeleteOperations,
|
||||
): Promise<UiWriteOutcome> {
|
||||
const result = await operations.delete<unknown>(
|
||||
`/api/v1/import-files/${plan.upload_id}`,
|
||||
);
|
||||
// Core answers 204 with no body. A successful delete is a committed
|
||||
// operation even though there is no receipt to validate; requiring a body
|
||||
// here would report every real discard as `ui_write_receipt_invalid`.
|
||||
return outcome(result, plan.upload_id, null);
|
||||
}
|
||||
|
||||
/** Upload one local file through the same recoverable Core staging flow the
|
||||
* Web console uses. The operation id is part of the plan, so a retry after a
|
||||
* dropped response resumes the same staging record instead of duplicating it.
|
||||
*/
|
||||
export async function executeImportFileUpload(
|
||||
plan: Extract<UiWritePlan, { kind: "import_file_upload" }>,
|
||||
operations: CoreUploadOperations,
|
||||
): Promise<UiWriteOutcome> {
|
||||
// Re-read the file at apply time and re-derive its identity. A plan that
|
||||
// names a path whose content changed after review is refused rather than
|
||||
// uploading something the caller never saw.
|
||||
const reread = await readImportFileUpload(plan.path, plan.input_kind);
|
||||
if (reread === null) {
|
||||
return invalid("import_file_upload_unreadable");
|
||||
}
|
||||
const bytes = reread.bytes;
|
||||
const checksum = createHash("sha256").update(bytes).digest("hex");
|
||||
if (
|
||||
bytes.byteLength !== plan.bytes ||
|
||||
checksum !== plan.sha256 ||
|
||||
reread.plan.digest !== plan.digest
|
||||
) {
|
||||
return invalid("import_file_upload_content_mismatch");
|
||||
}
|
||||
const result = await operations.uploadImportFile<unknown>({
|
||||
bytes,
|
||||
filename: plan.filename,
|
||||
inputKind: plan.input_kind,
|
||||
operationId: plan.operation_id,
|
||||
});
|
||||
if (result.state === "rejected") {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "rejected",
|
||||
operation_id: plan.operation_id,
|
||||
code: result.code,
|
||||
};
|
||||
}
|
||||
if (result.state === "unknown") {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "unknown",
|
||||
operation_id: plan.operation_id,
|
||||
code: result.code,
|
||||
};
|
||||
}
|
||||
const receipt =
|
||||
result.data !== null &&
|
||||
typeof result.data === "object" &&
|
||||
!Array.isArray(result.data)
|
||||
? (result.data as Record<string, unknown>)
|
||||
: null;
|
||||
if (
|
||||
receipt === null ||
|
||||
receipt.operation_id !== plan.operation_id ||
|
||||
typeof receipt.state !== "string"
|
||||
) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "unknown",
|
||||
operation_id: plan.operation_id,
|
||||
code: "ui_write_receipt_invalid",
|
||||
};
|
||||
}
|
||||
// Core reports the staged upload as `completed`; `pending`/`not_found` here
|
||||
// means the operation record did not settle, so the result is not known.
|
||||
if (receipt.state !== "completed") {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "unknown",
|
||||
operation_id: plan.operation_id,
|
||||
code: `import_upload_${receipt.state}`,
|
||||
};
|
||||
}
|
||||
const uploadId =
|
||||
typeof receipt.upload_id === "string" ? receipt.upload_id : undefined;
|
||||
if (uploadId === undefined || !UUID.test(uploadId)) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "unknown",
|
||||
operation_id: plan.operation_id,
|
||||
code: "ui_write_receipt_invalid",
|
||||
};
|
||||
}
|
||||
return committed(plan.operation_id, {
|
||||
operation_id: plan.operation_id,
|
||||
upload_id: uploadId,
|
||||
upload_state: receipt.upload_state ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function outcome(
|
||||
result:
|
||||
| Awaited<ReturnType<CoreOperations["post"]>>
|
||||
| Awaited<ReturnType<CoreDeleteOperations["delete"]>>,
|
||||
operationId: string,
|
||||
required: readonly string[] | null,
|
||||
): UiWriteOutcome {
|
||||
if (result.state === "rejected") {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "rejected",
|
||||
operation_id: operationId,
|
||||
code: result.code,
|
||||
};
|
||||
}
|
||||
if (result.state === "unknown") {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "unknown",
|
||||
operation_id: operationId,
|
||||
code: result.code,
|
||||
};
|
||||
}
|
||||
if (required === null) {
|
||||
return committed(operationId, {});
|
||||
}
|
||||
const data =
|
||||
result.data !== null &&
|
||||
typeof result.data === "object" &&
|
||||
!Array.isArray(result.data)
|
||||
? (result.data as Record<string, unknown>)
|
||||
: null;
|
||||
if (data === null || required.some((key) => !Object.hasOwn(data, key))) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "unknown",
|
||||
operation_id: operationId,
|
||||
code: "ui_write_receipt_invalid",
|
||||
};
|
||||
}
|
||||
return committed(operationId, data);
|
||||
}
|
||||
@@ -13,7 +13,16 @@ const GLOBAL_OPTIONS: readonly CommandOption[] = [
|
||||
summary: "显示帮助;可与 --json 组合输出机器可读目录。",
|
||||
},
|
||||
{ flag: "--version, -V", summary: "显示 CLI 版本。" },
|
||||
{ flag: "--json", summary: "以 JSON 输出到 stdout,供脚本与 Agent 消费。" },
|
||||
{
|
||||
flag: "--json",
|
||||
summary:
|
||||
"以 JSON 输出到 stdout,供脚本与 Agent 消费;结果与失败都在 stdout,只有参数错误写 stderr。",
|
||||
},
|
||||
{
|
||||
flag: "--data-dir <path>",
|
||||
summary:
|
||||
"本次使用的 MemHub 数据目录(绝对路径);默认 ONE_MEMORY_DATA_DIR > 安装回执 > ~/.one-memory。",
|
||||
},
|
||||
];
|
||||
|
||||
export function renderGeneralHelp(): string {
|
||||
@@ -33,6 +42,9 @@ export function renderGeneralHelp(): string {
|
||||
(exit) => ` ${exit.code} ${pad(exit.name, 17)}${exit.summary}`,
|
||||
),
|
||||
"",
|
||||
"确认约定:只有不可逆操作(memories delete、models remove、delete-data、backup restore、graph-width --decision rebuild_with_new)需要 --yes;不加时只说明将要做什么,不做任何改动,exit 0。delete-data 与 backup restore 还要用 --confirm <path> 再写一遍目标路径。install/upgrade/uninstall/service-install/service-uninstall 不加 --yes 时只出计划。其它写命令直接执行。",
|
||||
"异步操作返回 ID,用对应命令查进度:sources receipt、models receipt、memories add-status、write status。",
|
||||
"目前仅支持 macOS 与 Linux 连接本机 Runtime。",
|
||||
"stdout 只输出数据,进度与诊断写入 stderr。",
|
||||
`运行 ${PRODUCT} help <command> 查看单个命令的参数。`,
|
||||
"",
|
||||
@@ -55,6 +67,8 @@ export function renderCommandHelp(command: CommandDescriptor): string {
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
"Global options: --json、--data-dir <path>(见 memhub help)。",
|
||||
"",
|
||||
"Exit codes:",
|
||||
...EXIT_CODES.filter((exit) => command.exit_codes.includes(exit.code)).map(
|
||||
(exit) => ` ${exit.code} ${pad(exit.name, 17)}${exit.summary}`,
|
||||
|
||||
@@ -163,14 +163,14 @@ from one_memory.imports.supervisor import ImportSupervisor
|
||||
from one_memory.library_search_window import LibrarySearchWindows, window_key
|
||||
from one_memory.library_upgrade import LibraryUpgradeError, prepare_library
|
||||
from one_memory.loop_watchdog import LoopWatchdog
|
||||
from one_memory.memory_additions import (
|
||||
LegacyMemoryAdditionReader,
|
||||
MemoryAdditionStore,
|
||||
memory_add_router,
|
||||
)
|
||||
from one_memory.memory_governance import (
|
||||
MemoryGovernanceStore,
|
||||
)
|
||||
from one_memory.memory_notes import (
|
||||
LegacyMemoryNoteReader,
|
||||
MemoryNoteStore,
|
||||
memory_note_router,
|
||||
)
|
||||
from one_memory.memory_qa import MemoryAnswerError
|
||||
from one_memory.memory_queue import (
|
||||
ExtractionWindowPreset,
|
||||
@@ -1050,10 +1050,10 @@ def create_app(
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
app.state.memory_note_store = MemoryNoteStore(
|
||||
app.state.memory_addition_store = MemoryAdditionStore(
|
||||
app.state.evidence_store,
|
||||
(
|
||||
LegacyMemoryNoteReader(
|
||||
LegacyMemoryAdditionReader(
|
||||
app.state.memory_task_store,
|
||||
app.state.memory_governance_store,
|
||||
)
|
||||
@@ -1460,7 +1460,7 @@ def create_app(
|
||||
Depends(local_api_guard(runtime_settings, valid_ui_session=ui_session_valid)),
|
||||
Depends(require_external_endpoint),
|
||||
]
|
||||
app.include_router(memory_note_router(protected))
|
||||
app.include_router(memory_add_router(protected))
|
||||
app.include_router(file_import_router(protected))
|
||||
app.include_router(sdk_context_router(protected))
|
||||
|
||||
@@ -2856,11 +2856,14 @@ def create_app(
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=LIBRARY_PAGE_SIZE, ge=1, le=500),
|
||||
window_id: str | None = Query(default=None, min_length=1, max_length=64),
|
||||
scene: Literal["all", "chat", "working", "coding"] = Query(default="all"),
|
||||
) -> HySdkMemoryPage:
|
||||
"""One page of memories across chat, working and coding.
|
||||
|
||||
Without `query`: `list_memories(scene="all")`, newest update first;
|
||||
`total` is the SDK's count across the three scenes.
|
||||
`total` is the SDK's count across the three scenes. `scene` narrows
|
||||
the page to one scene: chat lists the chat store, working/coding list
|
||||
that scene's experiences (the SDK lists experiences only, all active).
|
||||
|
||||
With `query`: keyword search over one SDK relevance window (see
|
||||
`_library_search_page`). The first request (no `window_id`) searches
|
||||
@@ -2887,11 +2890,12 @@ def create_app(
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
window_id=window_id,
|
||||
scene=scene,
|
||||
)
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
scene="all",
|
||||
scene=scene,
|
||||
statuses=list(statuses),
|
||||
include_sources=[source] if source else None,
|
||||
offset=offset,
|
||||
@@ -4540,6 +4544,7 @@ async def _library_search_page(
|
||||
offset: int,
|
||||
limit: int,
|
||||
window_id: str | None,
|
||||
scene: str = "all",
|
||||
) -> HySdkMemoryPage:
|
||||
"""One page of a keyword search, cut from a cached result window.
|
||||
|
||||
@@ -4551,7 +4556,7 @@ async def _library_search_page(
|
||||
SDK so edits and deletes show. The whole library is never listed in Core.
|
||||
"""
|
||||
windows = _library_search_windows(app)
|
||||
key = window_key(query, source, statuses)
|
||||
key = window_key(query, source, statuses, scene)
|
||||
window = windows.get(window_id, key) if window_id else None
|
||||
window_reset = window_id is not None and window is None
|
||||
if window is None:
|
||||
@@ -4576,6 +4581,13 @@ async def _library_search_page(
|
||||
reader=HY_MEMORY_READER,
|
||||
)
|
||||
hits, held = library_keyword_hits(search_raw, query)
|
||||
if scene != "all":
|
||||
# One scene: keep that scene's matches from the same fused window.
|
||||
hits = tuple(
|
||||
hit
|
||||
for hit in hits
|
||||
if (hit.kind == "chat" if scene == "chat" else hit.variant == scene)
|
||||
)
|
||||
window = windows.put(key, hits, fetch if held >= fetch else None)
|
||||
if window_reset:
|
||||
offset = 0
|
||||
|
||||
@@ -314,6 +314,10 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
details={
|
||||
"runtime_build": runtime_build,
|
||||
"confirmed": confirmed,
|
||||
# The Runtime the upgrade replaced, as the orchestrator read it
|
||||
# from that Runtime's install manifest; never guessed.
|
||||
"source_version": _migration_source("ONE_MEMORY_MIGRATION_SOURCE_VERSION"),
|
||||
"source_build": _migration_source("ONE_MEMORY_MIGRATION_SOURCE_BUILD"),
|
||||
**authority_snapshot(database_path),
|
||||
},
|
||||
)
|
||||
@@ -386,6 +390,7 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
stream.write(f"{step_names.get(step, step)}\n")
|
||||
stream.flush()
|
||||
|
||||
restore: dict[str, object] = {}
|
||||
try:
|
||||
with _migration_lock(settings.data_dir):
|
||||
result = run_startup_migration(
|
||||
@@ -394,6 +399,7 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
sdk_version=HY_MEMORY_VERSION,
|
||||
namespace=HY_MEMORY_NAMESPACE,
|
||||
progress=progress,
|
||||
restore_report=restore,
|
||||
)
|
||||
except Exception as error:
|
||||
from one_memory.sdk_migration_verify import MemoryReadbackFailed
|
||||
@@ -402,6 +408,10 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
failed: dict[str, object] = {"schema_version": 1, "state": "failed", "code": code}
|
||||
if isinstance(error, MemoryReadbackFailed):
|
||||
failed["readback"] = error.report.as_document()
|
||||
# The migration's own code stays the result; the restore is secondary.
|
||||
failed["data_restored"] = restore.get("state") == "restored"
|
||||
if restore.get("state") == "failed":
|
||||
failed["restore_code"] = restore.get("code")
|
||||
write_migration_log(
|
||||
settings.data_dir,
|
||||
writer="core-sdk-migrate",
|
||||
@@ -417,10 +427,13 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
"code_is_fallback": code == "sdk_migration_preflight_failed"
|
||||
and not _names_code(error, code),
|
||||
**({"readback": failed["readback"]} if "readback" in failed else {}),
|
||||
"restore": restore.get("state", "not_attempted"),
|
||||
**({"restore_code": restore["code"]} if "code" in restore else {}),
|
||||
**authority_snapshot(database_path),
|
||||
},
|
||||
)
|
||||
_write_restore(stream, as_json, failed, f"迁移未完成:{code}")
|
||||
restored_text = ";数据已恢复到升级前的状态" if failed["data_restored"] else ""
|
||||
_write_restore(stream, as_json, failed, f"迁移未完成:{code}{restored_text}")
|
||||
return 1
|
||||
document = result.as_document()
|
||||
write_migration_log(
|
||||
@@ -446,6 +459,14 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
_SAFE_SOURCE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+\-]{0,63}$")
|
||||
|
||||
|
||||
def _migration_source(name: str) -> str:
|
||||
value = os.environ.get(name, "").strip()
|
||||
return value if _SAFE_SOURCE.fullmatch(value) else "unknown"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _migration_lock(data_dir: Path) -> Iterator[None]:
|
||||
"""One `sdk-migrate` per home at a time.
|
||||
@@ -545,8 +566,18 @@ def _sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
|
||||
as_json = "--json" in options
|
||||
settings = load_settings()
|
||||
database_path = settings.data_dir / "one-memory.sqlite3"
|
||||
if not database_path.is_file():
|
||||
from one_memory.sdk_migration_restore import restore_pending
|
||||
|
||||
if restore_pending(settings.data_dir):
|
||||
# A restore was killed between its renames; the database may be
|
||||
# elsewhere. Only `sdk-migrate` may finish it.
|
||||
document: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"authority": "legacy",
|
||||
"state": "required",
|
||||
}
|
||||
elif not database_path.is_file():
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
"authority": "sdk",
|
||||
"state": "not_required",
|
||||
@@ -619,8 +650,22 @@ def _refuse_unmigrated_home(data_dir: Path) -> None:
|
||||
"""
|
||||
from one_memory.sdk_data_plane_migration import MemoryAuthority, SdkDataPlaneMigrationStore
|
||||
from one_memory.sdk_migration_log import authority_snapshot, write_migration_log
|
||||
from one_memory.sdk_migration_restore import restore_pending
|
||||
|
||||
database_path = data_dir / "one-memory.sqlite3"
|
||||
if restore_pending(data_dir):
|
||||
# A killed restore may have moved the database aside: serving now
|
||||
# would create an empty home over the user's data.
|
||||
write_migration_log(
|
||||
data_dir,
|
||||
writer="core-startup",
|
||||
event="core.refused_legacy",
|
||||
outcome="refused",
|
||||
code="sdk_migration_required",
|
||||
details={"stage": "serve_entry", "restore_pending": True},
|
||||
)
|
||||
print("sdk_migration_required", file=sys.stderr, flush=True)
|
||||
raise SystemExit(SDK_MIGRATION_REQUIRED_EXIT)
|
||||
if not database_path.is_file():
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -227,7 +227,7 @@ def _trailing_user_turn_is_open(turn: list[EvidenceRecord]) -> bool:
|
||||
return (
|
||||
_opens_turn(opener)
|
||||
and opener.content_kind is EvidenceContentKind.RAW_CONVERSATION
|
||||
and not _is_memory_note(opener)
|
||||
and not _is_hand_added_memory(opener)
|
||||
)
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ def _is_hy_input_turn(record: EvidenceRecord) -> bool:
|
||||
return _HOST_ENVELOPE.search(source[:2_000]) is None
|
||||
|
||||
|
||||
def _is_memory_note(record: EvidenceRecord) -> bool:
|
||||
def _is_hand_added_memory(record: EvidenceRecord) -> bool:
|
||||
meta = record.origin_meta.get(EvidenceOrigin.MANUAL_IMPORT)
|
||||
return (
|
||||
record.source_id == "one-memory-manual"
|
||||
|
||||
@@ -229,6 +229,10 @@ class HySdkMemoryPage(BaseModel):
|
||||
raise HySdkReadError("hy_memory_list_shape_unknown")
|
||||
if raw.get("scene") == "all":
|
||||
return cls._parse_all_scenes(raw, limit=limit, offset=offset, skip_invalid=skip_invalid)
|
||||
if raw.get("scene") == "productivity":
|
||||
return cls._parse_experiences(
|
||||
raw, limit=limit, offset=offset, skip_invalid=skip_invalid
|
||||
)
|
||||
rows: list[object] = []
|
||||
total = 0
|
||||
for bucket_name, rows_name in (("vdb", "memories"), ("graph", "nodes")):
|
||||
@@ -325,6 +329,51 @@ class HySdkMemoryPage(BaseModel):
|
||||
skipped,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _parse_experiences(
|
||||
cls,
|
||||
raw: Mapping[str, object],
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
skip_invalid: bool,
|
||||
) -> tuple[HySdkMemoryPage, int]:
|
||||
"""``list_memories(scene="working"|"coding")``: that scene's experiences.
|
||||
|
||||
The SDK names the scene once, in ``variant``; each row is a bare
|
||||
experience, so the kind and variant are added before parsing.
|
||||
"""
|
||||
rows = raw.get("experiences")
|
||||
total = raw.get("total")
|
||||
variant = raw.get("variant")
|
||||
if (
|
||||
not isinstance(rows, list)
|
||||
or not isinstance(total, int)
|
||||
or isinstance(total, bool)
|
||||
or total < 0
|
||||
or variant not in {"working", "coding"}
|
||||
):
|
||||
raise HySdkReadError("hy_memory_list_shape_unknown")
|
||||
memories: list[HySdkMemoryRecord] = []
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
try:
|
||||
if not isinstance(row, Mapping):
|
||||
raise HySdkReadError("hy_memory_record_shape_unknown")
|
||||
record = HySdkMemoryRecord.from_sdk(
|
||||
{**row, "kind": "experience", "variant": variant}
|
||||
)
|
||||
except HySdkReadError:
|
||||
if not skip_invalid:
|
||||
raise
|
||||
skipped += 1
|
||||
continue
|
||||
memories.append(record)
|
||||
return (
|
||||
cls(memories=tuple(memories), total=total, limit=limit, offset=offset),
|
||||
skipped,
|
||||
)
|
||||
|
||||
|
||||
class HySdkRawRecord(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
@@ -7,8 +7,9 @@ between pages. The matches are kept here under an opaque ``window_id`` that
|
||||
the client passes back with the next offset.
|
||||
|
||||
A window is bound to the filters it was searched with (query, source,
|
||||
statuses). It expires after ``ttl_seconds`` without being read, and the store
|
||||
keeps at most ``max_windows`` windows, dropping the least recently read first.
|
||||
statuses, scene). It expires after ``ttl_seconds`` without being read, and the
|
||||
store keeps at most ``max_windows`` windows, dropping the least recently read
|
||||
first.
|
||||
Only memory identities are kept, never content: each page is read fresh from
|
||||
the SDK, so an edit or a delete shows on the next page read.
|
||||
"""
|
||||
@@ -27,11 +28,13 @@ from one_memory.hy_sdk_types import LibraryHit
|
||||
DEFAULT_WINDOW_TTL_SECONDS = 300.0
|
||||
DEFAULT_MAX_WINDOWS = 32
|
||||
|
||||
WindowKey = tuple[str, str | None, tuple[str, ...]]
|
||||
WindowKey = tuple[str, str | None, tuple[str, ...], str]
|
||||
|
||||
|
||||
def window_key(query: str, source: str | None, statuses: list[str]) -> WindowKey:
|
||||
return (query, source, tuple(sorted(set(statuses))))
|
||||
def window_key(
|
||||
query: str, source: str | None, statuses: list[str], scene: str = "all"
|
||||
) -> WindowKey:
|
||||
return (query, source, tuple(sorted(set(statuses))), scene)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -105,10 +105,17 @@ def capture_stdio(source_id: str, stdin: TextIO, output: TextIO) -> int:
|
||||
|
||||
|
||||
def sdk_migration_capture_fenced(data_dir: Path) -> bool:
|
||||
"""Ignore stale fences; only a live Desktop migration pauses hook writes."""
|
||||
"""Ignore stale fences; a live migration or a failed one pauses hook writes.
|
||||
|
||||
A failed upgrade migration leaves the fence `frozen` (no pid): the home was
|
||||
put back as it was before the upgrade and takes no writes until the next
|
||||
migration attempt, which replaces the fence and lifts it on success.
|
||||
"""
|
||||
marker = data_dir / SDK_MIGRATION_CAPTURE_FENCE
|
||||
try:
|
||||
document = json.loads(marker.read_text(encoding="utf-8"))
|
||||
if isinstance(document, dict) and document.get("frozen") is True:
|
||||
return True
|
||||
pid = int(document["pid"])
|
||||
if pid < 1:
|
||||
raise ValueError("invalid migration fence pid")
|
||||
|
||||
+72
-132
@@ -1,5 +1,13 @@
|
||||
"""Add one memory directly: `POST /api/v2/memories` and its status read.
|
||||
|
||||
The public API has no "notes": a hand-added memory is filed as manual Evidence
|
||||
and turned into memories by the durable write queue. Storage keeps its
|
||||
pre-rename identifiers (`memory_note_requests`, source `one-memory-manual`,
|
||||
`input_mode=direct_memory_note`, conversation `note:<request_id>`) because they
|
||||
are persisted and feed evidence ids; renaming them would change stored data.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
@@ -28,7 +36,7 @@ from one_memory.memory_tasks import MemoryTaskStatus, MemoryTaskStore
|
||||
from one_memory.secrets import SecretStoreUnavailableError
|
||||
|
||||
SOURCE_ID = "one-memory-manual"
|
||||
MemoryNoteState = Literal[
|
||||
MemoryAddState = Literal[
|
||||
"waiting_for_model",
|
||||
"waiting_for_permission",
|
||||
"pending",
|
||||
@@ -39,7 +47,7 @@ MemoryNoteState = Literal[
|
||||
]
|
||||
|
||||
|
||||
class MemoryNoteInput(BaseModel):
|
||||
class MemoryAddInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
request_id: UUID
|
||||
@@ -50,25 +58,25 @@ class MemoryNoteInput(BaseModel):
|
||||
@classmethod
|
||||
def meaningful_text(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("memory_note_empty")
|
||||
raise ValueError("memory_add_empty")
|
||||
return value
|
||||
|
||||
|
||||
class MemoryNoteReceipt(BaseModel):
|
||||
class MemoryAddReceipt(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
evidence_id: str
|
||||
text: str
|
||||
scope: Literal["all-apps"] = "all-apps"
|
||||
created_at: datetime
|
||||
state: MemoryNoteState
|
||||
state: MemoryAddState
|
||||
task_id: str | None = None
|
||||
memory_ids: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class LegacyMemoryNoteReader:
|
||||
"""Read pre-cutover note receipts without retaining any write runtime."""
|
||||
class LegacyMemoryAdditionReader:
|
||||
"""Read pre-cutover addition receipts without retaining any write runtime."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -79,7 +87,7 @@ class LegacyMemoryNoteReader:
|
||||
self.governance = governance
|
||||
|
||||
|
||||
class MemoryNoteStore:
|
||||
class MemoryAdditionStore:
|
||||
def __init__(
|
||||
self,
|
||||
evidence: EvidenceStore,
|
||||
@@ -92,34 +100,34 @@ class MemoryNoteStore:
|
||||
self.evidence = evidence
|
||||
self.processor = processor
|
||||
# D52: the production composition root passes a UnifiedWriteIntake so a
|
||||
# manual note reaches the durable queue like every other entry. It stays
|
||||
# manual addition reaches the durable queue like every other entry. It stays
|
||||
# optional so isolated tests can keep constructing the store directly.
|
||||
self.intake = intake
|
||||
self.sdk_authority = sdk_authority
|
||||
self.write_queue = write_queue
|
||||
|
||||
def save(self, note: MemoryNoteInput) -> EvidenceRecord:
|
||||
def save(self, addition: MemoryAddInput) -> EvidenceRecord:
|
||||
now = datetime.now(UTC)
|
||||
value = EvidenceInput(
|
||||
source_id=SOURCE_ID,
|
||||
conversation_id=f"note:{note.request_id}",
|
||||
message_id=str(note.request_id),
|
||||
conversation_id=f"note:{addition.request_id}",
|
||||
message_id=str(addition.request_id),
|
||||
role="user",
|
||||
written_by=WrittenBy.PERSON,
|
||||
created_at=now,
|
||||
verbatim=note.text,
|
||||
verbatim=addition.text,
|
||||
origin_meta={
|
||||
EvidenceOrigin.MANUAL_IMPORT: OriginMetadata(
|
||||
raw_meta={
|
||||
"input_mode": "direct_memory_note",
|
||||
"recall_scope": note.scope,
|
||||
"recall_scope": addition.scope,
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
if self.sdk_authority:
|
||||
if self.intake is None or self.write_queue is None:
|
||||
raise RuntimeError("sdk_memory_note_intake_unavailable")
|
||||
raise RuntimeError("sdk_memory_add_intake_unavailable")
|
||||
record = materialize_write_input(value, observed_at=now)
|
||||
with main_database(self.write_queue.database_path).read() as database:
|
||||
existing = database.execute(
|
||||
@@ -129,7 +137,7 @@ class MemoryNoteStore:
|
||||
if existing is not None:
|
||||
current = self.require(record.evidence_id)
|
||||
if current.verbatim != record.verbatim:
|
||||
raise ValueError("memory_note_request_conflict")
|
||||
raise ValueError("memory_add_conflict")
|
||||
return current
|
||||
receipt = self.intake.submit_records(
|
||||
(record,),
|
||||
@@ -159,13 +167,13 @@ class MemoryNoteStore:
|
||||
(SOURCE_ID,),
|
||||
).fetchone()
|
||||
if grant and grant[0] is not None:
|
||||
raise ValueError("memory_note_source_revoked")
|
||||
raise ValueError("memory_add_source_revoked")
|
||||
existing = db.execute(
|
||||
"SELECT verbatim FROM evidence WHERE evidence_id=?", (value.evidence_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
if existing[0] != note.text:
|
||||
raise ValueError("memory_note_request_conflict")
|
||||
if existing[0] != addition.text:
|
||||
raise ValueError("memory_add_conflict")
|
||||
else:
|
||||
self.evidence.put(value, connection=db)
|
||||
db.execute(
|
||||
@@ -218,10 +226,10 @@ class MemoryNoteStore:
|
||||
(SOURCE_ID,),
|
||||
).fetchone()
|
||||
if grant is None or grant[0] is not None:
|
||||
raise ValueError("memory_note_source_revoked")
|
||||
raise ValueError("memory_add_source_revoked")
|
||||
return record
|
||||
|
||||
def receipt(self, evidence_id: str) -> MemoryNoteReceipt:
|
||||
def receipt(self, evidence_id: str) -> MemoryAddReceipt:
|
||||
record = self.require(evidence_id)
|
||||
if self.sdk_authority:
|
||||
assert self.write_queue is not None
|
||||
@@ -232,7 +240,7 @@ class MemoryNoteStore:
|
||||
).fetchone()[0]
|
||||
durable = self.write_queue.get(str(request_id))
|
||||
durable_state = durable.state if durable is not None else "failed"
|
||||
mapped: MemoryNoteState = {
|
||||
mapped: MemoryAddState = {
|
||||
"pending": "pending",
|
||||
"running": "running",
|
||||
"waiting_for_model": "waiting_for_model",
|
||||
@@ -241,7 +249,7 @@ class MemoryNoteStore:
|
||||
"ready" if durable is not None and durable.sdk_memory_ids else "no_memories"
|
||||
),
|
||||
}.get(durable_state, "failed")
|
||||
return MemoryNoteReceipt(
|
||||
return MemoryAddReceipt(
|
||||
evidence_id=evidence_id,
|
||||
text=sanitize_model_input(record.verbatim),
|
||||
created_at=record.created_at or record.imported_at,
|
||||
@@ -250,7 +258,7 @@ class MemoryNoteStore:
|
||||
error=durable.detail if durable is not None else "write_missing",
|
||||
)
|
||||
if self.processor is None:
|
||||
raise RuntimeError("legacy_memory_note_processor_unavailable")
|
||||
raise RuntimeError("legacy_memory_add_processor_unavailable")
|
||||
task = self.processor.tasks.task_for_evidence(evidence_id)
|
||||
memory_ids = []
|
||||
if task:
|
||||
@@ -258,7 +266,7 @@ class MemoryNoteStore:
|
||||
memory = self.processor.governance.get(output.memory_id, user_id="local")
|
||||
if memory and memory.status is MemoryStatus.CONFIRMED:
|
||||
memory_ids.append(memory.memory_id)
|
||||
state: MemoryNoteState
|
||||
state: MemoryAddState
|
||||
if task is None or task.status == MemoryTaskStatus.WAITING_FOR_MODEL:
|
||||
state = "waiting_for_model"
|
||||
elif task.status == MemoryTaskStatus.WAITING_FOR_PERMISSION:
|
||||
@@ -274,7 +282,7 @@ class MemoryNoteStore:
|
||||
else:
|
||||
state = "failed"
|
||||
error = task.last_error_code if task else None
|
||||
return MemoryNoteReceipt(
|
||||
return MemoryAddReceipt(
|
||||
evidence_id=record.evidence_id,
|
||||
text=sanitize_model_input(record.verbatim),
|
||||
created_at=record.created_at or record.imported_at,
|
||||
@@ -284,33 +292,7 @@ class MemoryNoteStore:
|
||||
error=error,
|
||||
)
|
||||
|
||||
def list(self) -> list[MemoryNoteReceipt]:
|
||||
if self.sdk_authority:
|
||||
assert self.write_queue is not None
|
||||
with main_database(self.write_queue.database_path).read() as database:
|
||||
ids = [
|
||||
str(row[0])
|
||||
for row in database.execute(
|
||||
"SELECT note_id FROM memory_note_requests ORDER BY created_at DESC LIMIT 50"
|
||||
)
|
||||
]
|
||||
return [self.receipt(key) for key in ids]
|
||||
with main_database(self.evidence.database_path).read() as db:
|
||||
ids = [
|
||||
row[0]
|
||||
for row in db.execute(
|
||||
"SELECT e.evidence_id,o.raw_meta_json FROM evidence e "
|
||||
"JOIN source_grants g USING(user_id,source_id) "
|
||||
"JOIN evidence_origins o USING(evidence_id) WHERE e.user_id='local' "
|
||||
"AND e.source_id=? AND g.revoked_at IS NULL AND o.origin='manual_import' "
|
||||
"ORDER BY e.imported_at DESC,e.evidence_id LIMIT 50",
|
||||
(SOURCE_ID,),
|
||||
)
|
||||
if json.loads(row[1]).get("input_mode") == "direct_memory_note"
|
||||
]
|
||||
return [self.receipt(key) for key in ids]
|
||||
|
||||
def receipt_for_request(self, request_id: UUID) -> MemoryNoteReceipt:
|
||||
def receipt_for_request(self, request_id: UUID) -> MemoryAddReceipt:
|
||||
identity = str(request_id)
|
||||
if self.sdk_authority:
|
||||
probe = EvidenceInput(
|
||||
@@ -338,107 +320,65 @@ class MemoryNoteStore:
|
||||
return self.receipt(str(row[0]))
|
||||
|
||||
|
||||
def memory_note_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/memory-notes", tags=["memory-notes"], dependencies=dependencies
|
||||
)
|
||||
def memory_add_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
"""`POST /api/v2/memories` and `GET /api/v2/memories/additions/{request_id}`.
|
||||
|
||||
def store(request: Request) -> MemoryNoteStore:
|
||||
return cast(MemoryNoteStore, request.app.state.memory_note_store)
|
||||
Both paths are outside the single-segment `/api/v2/memories/{memory_id}`
|
||||
routes; external (enveloped) callers are refused by
|
||||
`require_external_endpoint` because neither is a broker read route.
|
||||
"""
|
||||
router = APIRouter(prefix="/api/v2/memories", tags=["memories"], dependencies=dependencies)
|
||||
|
||||
async def schedule(
|
||||
notes: MemoryNoteStore,
|
||||
record: EvidenceRecord,
|
||||
*,
|
||||
readmit: bool = False,
|
||||
) -> MemoryNoteReceipt:
|
||||
def store(request: Request) -> MemoryAdditionStore:
|
||||
return cast(MemoryAdditionStore, request.app.state.memory_addition_store)
|
||||
|
||||
async def schedule(additions: MemoryAdditionStore, record: EvidenceRecord) -> None:
|
||||
try:
|
||||
if notes.sdk_authority:
|
||||
return await asyncio.to_thread(notes.receipt, record.evidence_id)
|
||||
if notes.processor is None:
|
||||
raise RuntimeError("legacy_memory_note_processor_unavailable")
|
||||
if notes.intake is None:
|
||||
if additions.sdk_authority:
|
||||
return
|
||||
if additions.processor is None:
|
||||
raise RuntimeError("legacy_memory_add_processor_unavailable")
|
||||
if additions.intake is None:
|
||||
# Isolated tests construct the store without a queue.
|
||||
await notes.processor.enqueue_conversation_off_thread(record)
|
||||
await additions.processor.enqueue_conversation_off_thread(record)
|
||||
else:
|
||||
# Production: submit to the durable queue first. The drain runs
|
||||
# the same supervisor path, so create/process semantics are
|
||||
# unchanged; replaying the same request returns the original
|
||||
# `entry:<evidence_id>` job instead of a second HY call.
|
||||
if readmit:
|
||||
# `/{evidence_id}/process` is the explicit retry signal.
|
||||
# It also revives pre-upgrade permission waiters.
|
||||
await asyncio.to_thread(notes.intake.queue.readmit_parked)
|
||||
submitted = await asyncio.to_thread(
|
||||
notes.intake.submit_evidence,
|
||||
# Production: submit to the durable queue. Replaying the same
|
||||
# request returns the original `entry:<evidence_id>` job
|
||||
# instead of a second HY call.
|
||||
await asyncio.to_thread(
|
||||
additions.intake.submit_evidence,
|
||||
record,
|
||||
first_round=1,
|
||||
last_round=1,
|
||||
priority="live",
|
||||
)
|
||||
if readmit:
|
||||
await asyncio.to_thread(
|
||||
notes.intake.readmit_cancelled_evidence,
|
||||
record,
|
||||
job_id=submitted.record.job_id,
|
||||
first_round=1,
|
||||
last_round=1,
|
||||
priority="live",
|
||||
)
|
||||
except SecretStoreUnavailableError:
|
||||
saved = await asyncio.to_thread(notes.receipt, record.evidence_id)
|
||||
return saved.model_copy(
|
||||
update={"state": "waiting_for_model", "error": "secure_storage_unavailable"}
|
||||
)
|
||||
return await asyncio.to_thread(notes.receipt, record.evidence_id)
|
||||
# The receipt already reports the saved text; the queue retries
|
||||
# once secure storage is back.
|
||||
return
|
||||
|
||||
@router.post("", response_model=MemoryNoteReceipt, status_code=201)
|
||||
async def create(
|
||||
note: MemoryNoteInput,
|
||||
@router.post("", response_model=MemoryAddReceipt, status_code=201)
|
||||
async def add_memory(
|
||||
addition: MemoryAddInput,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
) -> MemoryNoteReceipt:
|
||||
notes = store(request)
|
||||
) -> MemoryAddReceipt:
|
||||
additions = store(request)
|
||||
try:
|
||||
record = await asyncio.to_thread(notes.save, note)
|
||||
record = await asyncio.to_thread(additions.save, addition)
|
||||
except ValueError as error:
|
||||
raise HTTPException(409, str(error)) from error
|
||||
saved = await asyncio.to_thread(notes.receipt, record.evidence_id)
|
||||
background.add_task(schedule, notes, record)
|
||||
saved = await asyncio.to_thread(additions.receipt, record.evidence_id)
|
||||
background.add_task(schedule, additions, record)
|
||||
return saved
|
||||
|
||||
@router.get("", response_model=list[MemoryNoteReceipt])
|
||||
def listing(request: Request) -> list[MemoryNoteReceipt]:
|
||||
return store(request).list()
|
||||
|
||||
@router.get("/operations/{request_id}", response_model=MemoryNoteReceipt)
|
||||
def operation(request_id: UUID, request: Request) -> MemoryNoteReceipt:
|
||||
@router.get("/additions/{request_id}", response_model=MemoryAddReceipt)
|
||||
def memory_add_status(request_id: UUID, request: Request) -> MemoryAddReceipt:
|
||||
try:
|
||||
return store(request).receipt_for_request(request_id)
|
||||
except KeyError as error:
|
||||
raise HTTPException(404, "memory_note_operation_not_found") from error
|
||||
|
||||
@router.get("/{evidence_id}", response_model=MemoryNoteReceipt)
|
||||
def get(evidence_id: str, request: Request) -> MemoryNoteReceipt:
|
||||
try:
|
||||
return store(request).receipt(evidence_id)
|
||||
except KeyError as error:
|
||||
raise HTTPException(404, "memory_note_not_found") from error
|
||||
raise HTTPException(404, "memory_add_not_found") from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(409, str(error)) from error
|
||||
|
||||
@router.post("/{evidence_id}/process", response_model=MemoryNoteReceipt)
|
||||
async def process(evidence_id: str, request: Request) -> MemoryNoteReceipt:
|
||||
notes = store(request)
|
||||
try:
|
||||
record = await asyncio.to_thread(notes.require, evidence_id)
|
||||
except KeyError as error:
|
||||
raise HTTPException(404, "memory_note_not_found") from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(409, str(error)) from error
|
||||
# This route is the explicit "the condition that parked it may have
|
||||
# changed" signal (the person just granted consent or fixed the model),
|
||||
# which is when `readmit_parked` is meant to run.
|
||||
return await schedule(notes, record, readmit=True)
|
||||
|
||||
return router
|
||||
@@ -98,23 +98,6 @@ class UnifiedWriteIntake:
|
||||
enqueued=durable.enqueued,
|
||||
)
|
||||
|
||||
def readmit_cancelled_evidence(
|
||||
self,
|
||||
record: EvidenceRecord,
|
||||
*,
|
||||
job_id: str,
|
||||
first_round: int,
|
||||
last_round: int,
|
||||
priority: WritePriority = "normal",
|
||||
) -> int:
|
||||
request = self._evidence_request(
|
||||
record,
|
||||
first_round=first_round,
|
||||
last_round=last_round,
|
||||
priority=priority,
|
||||
)
|
||||
return self.queue.readmit_cancelled_permission_confirmed(job_id, request)
|
||||
|
||||
@staticmethod
|
||||
def _evidence_request(
|
||||
record: EvidenceRecord,
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
"""Put a home back exactly as it was when a failed `sdk-migrate` started.
|
||||
|
||||
A migration that fails before SDK authority is committed has already changed
|
||||
the home: schema migrations on `one-memory.sqlite3`, legacy work moved into the
|
||||
durable queue, renamed Chroma collections under `hy-memory/governed-v2`, ledger
|
||||
rows. The fixed release that follows must start from the original data, not
|
||||
from that half-way state, so the runner restores the verified full-home bundle
|
||||
it took before the first change (`restore point`).
|
||||
|
||||
The restore is staged and then swapped in with renames:
|
||||
|
||||
1. the bundle is verified (every file hashed against its manifest);
|
||||
2. its database, config and SDK root are copied next to their live places and
|
||||
verified again;
|
||||
3. a journal lists every rename, then the live items (database with its
|
||||
`-wal`/`-shm`, `config.json`, `hy-memory/governed-v2`) are renamed into
|
||||
`backups/sdk-migration-failed/` and the staged copies are renamed in.
|
||||
|
||||
A rename either happened or did not, and each step is idempotent, so a process
|
||||
killed during step 3 is finished by the next run (`finish_interrupted_restore`)
|
||||
before anything else reads the home. Core refuses to serve and the status
|
||||
probe reports `required` while a journal exists.
|
||||
|
||||
After SDK authority is committed nothing is restored: the home already belongs
|
||||
to the SDK and the legacy tables may be gone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from one_memory.database_access import close_main_databases
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
|
||||
from one_memory.sdk_data_plane_migration import DataPlaneMigrationError
|
||||
|
||||
RESTORE_POINT = ".sdk-migration-restore-point.json"
|
||||
RESTORE_STAGING = ".sdk-migration-restore"
|
||||
JOURNAL = "journal.json"
|
||||
FAILED_STATE = "sdk-migration-failed"
|
||||
RESTORED_MARK = "restored.json"
|
||||
DATABASE = "one-memory.sqlite3"
|
||||
CONFIG = "config.json"
|
||||
_DATABASE_SIDECARS = ("-wal", "-shm", "-journal")
|
||||
_WRITER = "core-sdk-migrate"
|
||||
|
||||
|
||||
class MigrationRestoreFailed(DataPlaneMigrationError):
|
||||
"""The pre-migration data could not be put back; the home stays blocked."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RestoreResult:
|
||||
bundle_id: str
|
||||
files: int
|
||||
bytes: int
|
||||
duration_ms: float
|
||||
|
||||
def as_document(self) -> dict[str, object]:
|
||||
return {
|
||||
"bundle_id": self.bundle_id,
|
||||
"files": self.files,
|
||||
"bytes": self.bytes,
|
||||
"duration_ms": self.duration_ms,
|
||||
}
|
||||
|
||||
|
||||
def _home(data_dir: Path) -> Path:
|
||||
return data_dir.resolve()
|
||||
|
||||
|
||||
def _staging(home: Path) -> Path:
|
||||
return home / RESTORE_STAGING
|
||||
|
||||
|
||||
def _journal(home: Path) -> Path:
|
||||
return _staging(home) / JOURNAL
|
||||
|
||||
|
||||
def _sdk_staging(home: Path) -> Path:
|
||||
return home / "hy-memory" / f".{HY_MEMORY_NAMESPACE}.restore-staging"
|
||||
|
||||
|
||||
def failed_state_path(data_dir: Path) -> Path:
|
||||
return _home(data_dir) / "backups" / FAILED_STATE
|
||||
|
||||
|
||||
def _write_json(path: Path, document: dict[str, object]) -> None:
|
||||
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(document, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.chmod(temporary, 0o600)
|
||||
_fsync(temporary)
|
||||
temporary.replace(path)
|
||||
_fsync_directory(path.parent)
|
||||
|
||||
|
||||
def _fsync(path: Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
# Windows cannot open a directory; its renames are durable on return.
|
||||
try:
|
||||
_fsync(path)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def _log(
|
||||
data_dir: Path,
|
||||
event: str,
|
||||
outcome: str,
|
||||
*,
|
||||
migration_id: str | None,
|
||||
code: str | None = None,
|
||||
error: BaseException | None = None,
|
||||
details: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
from one_memory.sdk_migration_log import write_migration_log
|
||||
|
||||
write_migration_log(
|
||||
data_dir,
|
||||
writer=_WRITER,
|
||||
event=event,
|
||||
outcome=outcome,
|
||||
migration_id=migration_id,
|
||||
code=code,
|
||||
error=error,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
# --- restore point -----------------------------------------------------------
|
||||
|
||||
|
||||
def record_restore_point(data_dir: Path, *, migration_id: str, bundle_id: str) -> None:
|
||||
"""Name the verified bundle that holds this home as the migration found it.
|
||||
|
||||
Written before the first change, so a run killed part-way is rolled back
|
||||
by the next one instead of resumed.
|
||||
"""
|
||||
_write_json(
|
||||
_home(data_dir) / RESTORE_POINT,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"migration_id": migration_id,
|
||||
"bundle_id": bundle_id,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def read_restore_point(data_dir: Path) -> dict[str, str] | None:
|
||||
try:
|
||||
document = json.loads((_home(data_dir) / RESTORE_POINT).read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError) as error:
|
||||
raise MigrationRestoreFailed("sdk_migration_restore_point_invalid") from error
|
||||
if (
|
||||
not isinstance(document, dict)
|
||||
or document.get("schema_version") != 1
|
||||
or not isinstance(document.get("migration_id"), str)
|
||||
or not isinstance(document.get("bundle_id"), str)
|
||||
):
|
||||
raise MigrationRestoreFailed("sdk_migration_restore_point_invalid")
|
||||
return {"migration_id": document["migration_id"], "bundle_id": document["bundle_id"]}
|
||||
|
||||
|
||||
def clear_restore_point(data_dir: Path) -> None:
|
||||
(_home(data_dir) / RESTORE_POINT).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def restore_pending(data_dir: Path) -> bool:
|
||||
"""A restore was interrupted between its renames; nothing may read the home."""
|
||||
return _journal(_home(data_dir)).is_file()
|
||||
|
||||
|
||||
# --- restore -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _release_handles() -> None:
|
||||
"""Close what this process holds on the home; Windows cannot rename open files."""
|
||||
close_main_databases()
|
||||
chromadb = sys.modules.get("chromadb")
|
||||
if chromadb is None:
|
||||
return
|
||||
try:
|
||||
from chromadb.api.shared_system_client import SharedSystemClient
|
||||
|
||||
SharedSystemClient.clear_system_cache()
|
||||
except Exception: # noqa: BLE001 - best effort; a held file fails the rename instead
|
||||
return
|
||||
|
||||
|
||||
def _verify_copy(copy: Path, expected: list[dict[str, object]], prefix: str) -> None:
|
||||
"""The staged tree holds exactly the bundle's files under `prefix`."""
|
||||
wanted = {
|
||||
str(item["path"])[len(prefix) :]: item
|
||||
for item in expected
|
||||
if str(item["path"]).startswith(prefix)
|
||||
}
|
||||
present = (
|
||||
{path.relative_to(copy).as_posix() for path in copy.rglob("*") if path.is_file()}
|
||||
if copy.is_dir()
|
||||
else set()
|
||||
)
|
||||
if present != set(wanted):
|
||||
raise MigrationRestoreFailed("sdk_migration_restore_verify_failed")
|
||||
for relative, item in wanted.items():
|
||||
path = copy / relative
|
||||
if path.stat().st_size != int(str(item["bytes"])) or _sha256(path) != item["sha256"]:
|
||||
raise MigrationRestoreFailed("sdk_migration_restore_verify_failed")
|
||||
|
||||
|
||||
def _plan(home: Path, *, config_staged: bool) -> list[tuple[str, str]]:
|
||||
"""Every rename of the swap, as paths relative to the home, in order."""
|
||||
failed = Path("backups") / FAILED_STATE
|
||||
staging = Path(RESTORE_STAGING)
|
||||
sdk_live = Path("hy-memory") / HY_MEMORY_NAMESPACE
|
||||
steps: list[tuple[Path, Path]] = [
|
||||
(Path(f"{DATABASE}{suffix}"), failed / "management" / f"{DATABASE}{suffix}")
|
||||
for suffix in _DATABASE_SIDECARS
|
||||
]
|
||||
steps += [
|
||||
(Path(DATABASE), failed / "management" / DATABASE),
|
||||
(Path(CONFIG), failed / "management" / CONFIG),
|
||||
(sdk_live, failed / "sdk" / HY_MEMORY_NAMESPACE),
|
||||
(staging / "management" / DATABASE, Path(DATABASE)),
|
||||
(_sdk_staging(home).relative_to(home), sdk_live),
|
||||
]
|
||||
if config_staged:
|
||||
steps.append((staging / "management" / CONFIG, Path(CONFIG)))
|
||||
return [(source.as_posix(), target.as_posix()) for source, target in steps]
|
||||
|
||||
|
||||
def _apply(
|
||||
home: Path,
|
||||
steps: list[list[str]] | list[tuple[str, str]],
|
||||
done: list[tuple[Path, Path]],
|
||||
) -> None:
|
||||
"""Run each rename once; a step whose source is already gone is skipped.
|
||||
|
||||
`done` collects the renames this call made, so a caller can undo them.
|
||||
"""
|
||||
for source_name, target_name in steps:
|
||||
source, target = home / source_name, home / target_name
|
||||
source_exists = source.exists() or source.is_symlink()
|
||||
target_exists = target.exists() or target.is_symlink()
|
||||
if not source_exists:
|
||||
continue
|
||||
if target_exists:
|
||||
raise MigrationRestoreFailed("sdk_migration_restore_conflict")
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
source.replace(target)
|
||||
done.append((source, target))
|
||||
for directory in (home, home / "hy-memory"):
|
||||
_fsync_directory(directory)
|
||||
|
||||
|
||||
def _finish(home: Path, bundle_id: str) -> None:
|
||||
# The restore point goes first: a kill after it leaves only the journal,
|
||||
# whose steps are all done, and the next run just tidies up.
|
||||
clear_restore_point(home)
|
||||
shutil.rmtree(_sdk_staging(home), ignore_errors=True)
|
||||
_journal(home).unlink(missing_ok=True)
|
||||
shutil.rmtree(_staging(home), ignore_errors=True)
|
||||
bundle = home / "backups" / f"{bundle_id}.sdk-bundle"
|
||||
try:
|
||||
# A later verified bundle supersedes this one (`prune_restored_bundles`).
|
||||
_write_json(
|
||||
bundle / RESTORED_MARK,
|
||||
{"schema_version": 1, "restored_at": datetime.now(UTC).isoformat()},
|
||||
)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def restore_pre_migration_state(
|
||||
data_dir: Path,
|
||||
*,
|
||||
bundle_id: str,
|
||||
migration_id: str | None,
|
||||
reason: str,
|
||||
) -> RestoreResult:
|
||||
"""Put the restore-point bundle back in place of the half-migrated home.
|
||||
|
||||
The caller holds the migration lock with Runtime stopped and host capture
|
||||
fenced. Raises `MigrationRestoreFailed`; before the swap the home is left as
|
||||
the failure left it, and the restore point stays for the next run.
|
||||
"""
|
||||
home = _home(data_dir)
|
||||
started = time.monotonic()
|
||||
_log(
|
||||
home,
|
||||
"restore.started",
|
||||
"started",
|
||||
migration_id=migration_id,
|
||||
details={"bundle_id": bundle_id, "reason": reason},
|
||||
)
|
||||
try:
|
||||
result = _restore(home, bundle_id=bundle_id, started=started)
|
||||
except BaseException as error:
|
||||
code = getattr(error, "code", None)
|
||||
if not (isinstance(code, str) and code.startswith("sdk_migration_restore")):
|
||||
code = (
|
||||
"sdk_migration_restore_disk_space_insufficient"
|
||||
if isinstance(error, OSError) and error.errno in {errno.ENOSPC, errno.EDQUOT}
|
||||
else "sdk_migration_restore_failed"
|
||||
)
|
||||
_log(
|
||||
home,
|
||||
"restore.failed",
|
||||
"failed",
|
||||
migration_id=migration_id,
|
||||
code=code,
|
||||
error=error,
|
||||
details={
|
||||
"bundle_id": bundle_id,
|
||||
"reason": reason,
|
||||
"duration_ms": (time.monotonic() - started) * 1000,
|
||||
"swap_pending": restore_pending(home),
|
||||
},
|
||||
)
|
||||
if not isinstance(error, Exception) or (
|
||||
isinstance(error, MigrationRestoreFailed) and error.code == code
|
||||
):
|
||||
raise
|
||||
raise MigrationRestoreFailed(code) from error
|
||||
_log(
|
||||
home,
|
||||
"restore.finished",
|
||||
"succeeded",
|
||||
migration_id=migration_id,
|
||||
details={**result.as_document(), "reason": reason},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _restore(home: Path, *, bundle_id: str, started: float) -> RestoreResult:
|
||||
from one_memory.sdk_migration_bundle import inspect_sdk_migration_bundle
|
||||
|
||||
if restore_pending(home):
|
||||
raise MigrationRestoreFailed("sdk_migration_restore_pending")
|
||||
manifest = inspect_sdk_migration_bundle(data_dir=home, bundle_id=bundle_id)
|
||||
bundle = home / "backups" / f"{bundle_id}.sdk-bundle"
|
||||
listed = manifest.get("files")
|
||||
files = [item for item in listed if isinstance(item, dict)] if isinstance(listed, list) else []
|
||||
total = sum(int(str(item["bytes"])) for item in files)
|
||||
if shutil.disk_usage(home).free < total:
|
||||
raise MigrationRestoreFailed("sdk_migration_restore_disk_space_insufficient")
|
||||
_release_handles()
|
||||
|
||||
staging = _staging(home)
|
||||
sdk_staging = _sdk_staging(home)
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
shutil.rmtree(sdk_staging, ignore_errors=True)
|
||||
(staging / "management").mkdir(parents=True, mode=0o700)
|
||||
try:
|
||||
shutil.copy2(bundle / "management" / DATABASE, staging / "management" / DATABASE)
|
||||
config_staged = manifest.get("config") is not None
|
||||
if config_staged:
|
||||
shutil.copy2(bundle / "management" / CONFIG, staging / "management" / CONFIG)
|
||||
_verify_copy(staging / "management", files, "management/")
|
||||
sdk_source = bundle / "sdk" / HY_MEMORY_NAMESPACE
|
||||
if not sdk_source.is_dir() or sdk_source.is_symlink():
|
||||
raise MigrationRestoreFailed("sdk_migration_restore_bundle_incomplete")
|
||||
sdk_staging.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(sdk_source, sdk_staging, symlinks=True)
|
||||
_verify_copy(sdk_staging, files, f"sdk/{HY_MEMORY_NAMESPACE}/")
|
||||
for path in (staging / "management").iterdir():
|
||||
_fsync(path)
|
||||
except BaseException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
shutil.rmtree(sdk_staging, ignore_errors=True)
|
||||
raise
|
||||
|
||||
failed = failed_state_path(home)
|
||||
# One failed state is kept for diagnosis; the newest replaces the last.
|
||||
shutil.rmtree(failed, ignore_errors=True)
|
||||
if failed.exists():
|
||||
raise MigrationRestoreFailed("sdk_migration_restore_failed_state_busy")
|
||||
steps = _plan(home, config_staged=config_staged)
|
||||
_write_json(
|
||||
_journal(home),
|
||||
{"schema_version": 1, "bundle_id": bundle_id, "steps": [list(step) for step in steps]},
|
||||
)
|
||||
done: list[tuple[Path, Path]] = []
|
||||
try:
|
||||
_apply(home, steps, done)
|
||||
except BaseException as error:
|
||||
if not isinstance(error, Exception):
|
||||
# Interrupted (a signal tearing the process down): leave the
|
||||
# journal; the next run finishes the swap forward.
|
||||
raise
|
||||
try:
|
||||
for source, target in reversed(done):
|
||||
target.replace(source)
|
||||
except OSError:
|
||||
# The journal stays: the next run finishes the swap forward.
|
||||
raise MigrationRestoreFailed("sdk_migration_restore_swap_interrupted") from error
|
||||
_journal(home).unlink(missing_ok=True)
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
shutil.rmtree(sdk_staging, ignore_errors=True)
|
||||
raise
|
||||
_finish(home, bundle_id)
|
||||
return RestoreResult(
|
||||
bundle_id=bundle_id,
|
||||
files=len(files),
|
||||
bytes=total,
|
||||
duration_ms=(time.monotonic() - started) * 1000,
|
||||
)
|
||||
|
||||
|
||||
def finish_interrupted_restore(data_dir: Path, *, migration_id: str | None = None) -> bool:
|
||||
"""Complete a swap a killed process left between its renames. True if it did."""
|
||||
home = _home(data_dir)
|
||||
journal = _journal(home)
|
||||
if not journal.is_file():
|
||||
return False
|
||||
started = time.monotonic()
|
||||
try:
|
||||
document = json.loads(journal.read_text(encoding="utf-8"))
|
||||
steps = document["steps"]
|
||||
bundle_id = str(document["bundle_id"])
|
||||
if not isinstance(steps, list) or not all(
|
||||
isinstance(step, list) and len(step) == 2 for step in steps
|
||||
):
|
||||
raise ValueError("journal steps invalid")
|
||||
_release_handles()
|
||||
_apply(home, steps, [])
|
||||
_finish(home, bundle_id)
|
||||
except Exception as error:
|
||||
code = (
|
||||
error.code
|
||||
if isinstance(error, MigrationRestoreFailed)
|
||||
else "sdk_migration_restore_resume_failed"
|
||||
)
|
||||
_log(
|
||||
home,
|
||||
"restore.failed",
|
||||
"failed",
|
||||
migration_id=migration_id,
|
||||
code=code,
|
||||
error=error,
|
||||
details={"reason": "interrupted_swap", "swap_pending": True},
|
||||
)
|
||||
if isinstance(error, MigrationRestoreFailed):
|
||||
raise
|
||||
raise MigrationRestoreFailed(code) from error
|
||||
_log(
|
||||
home,
|
||||
"restore.finished",
|
||||
"succeeded",
|
||||
migration_id=migration_id,
|
||||
details={
|
||||
"bundle_id": bundle_id,
|
||||
"reason": "interrupted_swap",
|
||||
"duration_ms": (time.monotonic() - started) * 1000,
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def prune_restored_bundles(data_dir: Path, *, keep: set[str]) -> int:
|
||||
"""Drop bundles already restored, once a newer verified bundle exists.
|
||||
|
||||
Each holds the same pre-migration home a newer bundle now holds (capture
|
||||
stayed fenced since), so it is only a second copy of it.
|
||||
"""
|
||||
backups = _home(data_dir) / "backups"
|
||||
removed = 0
|
||||
for mark in backups.glob(f"*.sdk-bundle/{RESTORED_MARK}"):
|
||||
bundle = mark.parent
|
||||
if bundle.name.removesuffix(".sdk-bundle") in keep:
|
||||
continue
|
||||
shutil.rmtree(bundle, ignore_errors=True)
|
||||
removed += 0 if bundle.exists() else 1
|
||||
return removed
|
||||
|
||||
|
||||
def discard_failed_state(data_dir: Path) -> None:
|
||||
"""The failed half-migrated copy is only useful until a migration succeeds."""
|
||||
shutil.rmtree(failed_state_path(data_dir), ignore_errors=True)
|
||||
@@ -18,6 +18,7 @@ import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -483,30 +484,50 @@ def _resumable_identity(database_path: Path) -> tuple[str, str]:
|
||||
return migration_id, f"{migration_id}-pre-cutover"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def capture_fence(data_dir: Path) -> Iterator[None]:
|
||||
"""Host capture hooks return silently while this process is alive."""
|
||||
def _write_capture_fence(data_dir: Path, document: dict[str, object]) -> None:
|
||||
marker = data_dir / CAPTURE_FENCE
|
||||
temporary = marker.with_name(f"{marker.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(
|
||||
json.dumps({"schema_version": 1, "pid": os.getpid()}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.write_text(json.dumps(document) + "\n", encoding="utf-8")
|
||||
os.chmod(temporary, 0o600)
|
||||
temporary.replace(marker)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def capture_fence(data_dir: Path) -> Iterator[None]:
|
||||
"""Host capture hooks return silently while this process is alive.
|
||||
|
||||
A failed run leaves the fence frozen instead of lifting it: the home was
|
||||
put back (or is half-migrated) and must not take new writes until the next
|
||||
migration attempt replaces the fence with its own and succeeds.
|
||||
"""
|
||||
marker = data_dir / CAPTURE_FENCE
|
||||
_write_capture_fence(data_dir, {"schema_version": 1, "pid": os.getpid()})
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
except BaseException:
|
||||
try:
|
||||
_write_capture_fence(
|
||||
data_dir, {"schema_version": 1, "frozen": True, "reason": "sdk_migration_failed"}
|
||||
)
|
||||
except OSError:
|
||||
marker.unlink(missing_ok=True)
|
||||
raise
|
||||
else:
|
||||
marker.unlink(missing_ok=True)
|
||||
temporary.unlink(missing_ok=True)
|
||||
finally:
|
||||
marker.with_name(f"{marker.name}.{os.getpid()}.tmp").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _ensure_bundle(*, data_dir: Path, bundle_id: str, runtime_build: str) -> dict[str, object]:
|
||||
def _ensure_bundle(
|
||||
*, data_dir: Path, bundle_id: str, runtime_build: str
|
||||
) -> tuple[dict[str, object], bool]:
|
||||
"""The verified bundle, and whether this call created it."""
|
||||
from one_memory.sdk_migration_bundle import (
|
||||
MigrationBundleRejected,
|
||||
create_sdk_migration_bundle,
|
||||
)
|
||||
|
||||
created = True
|
||||
try:
|
||||
create_sdk_migration_bundle(
|
||||
data_dir=data_dir,
|
||||
@@ -517,7 +538,67 @@ def _ensure_bundle(*, data_dir: Path, bundle_id: str, runtime_build: str) -> dic
|
||||
except MigrationBundleRejected as error:
|
||||
if error.code != "sdk_migration_bundle_exists":
|
||||
raise
|
||||
return inspect_sdk_migration_bundle(data_dir=data_dir, bundle_id=bundle_id)
|
||||
created = False
|
||||
return inspect_sdk_migration_bundle(data_dir=data_dir, bundle_id=bundle_id), created
|
||||
|
||||
|
||||
def _restore_point_bundle(
|
||||
*, data_dir: Path, migration_id: str, bundle_id: str, created: bool, runtime_build: str
|
||||
) -> str:
|
||||
"""The bundle that holds this home exactly as this run found it.
|
||||
|
||||
The cutover bundle qualifies when this run created it. One left by an
|
||||
earlier run (an older build's blocked ledger row) may predate later writes,
|
||||
so restoring it could drop them; a fresh restore-point bundle is taken.
|
||||
"""
|
||||
if created:
|
||||
return bundle_id
|
||||
from one_memory.sdk_migration_bundle import create_sdk_migration_bundle
|
||||
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
|
||||
restore_id = f"{migration_id}-restore-point-{stamp}"
|
||||
create_sdk_migration_bundle(
|
||||
data_dir=data_dir,
|
||||
bundle_id=restore_id,
|
||||
runtime_build=runtime_build,
|
||||
runtime_stopped=True,
|
||||
)
|
||||
inspect_sdk_migration_bundle(data_dir=data_dir, bundle_id=restore_id)
|
||||
return restore_id
|
||||
|
||||
|
||||
def _recover_interrupted_attempt(data_dir: Path, database_path: Path, log: _StepLog) -> None:
|
||||
"""Roll back what a killed earlier run left, before anything reads the home."""
|
||||
from one_memory.sdk_migration_restore import (
|
||||
MigrationRestoreFailed,
|
||||
clear_restore_point,
|
||||
finish_interrupted_restore,
|
||||
read_restore_point,
|
||||
restore_pre_migration_state,
|
||||
)
|
||||
|
||||
finish_interrupted_restore(data_dir)
|
||||
point = read_restore_point(data_dir)
|
||||
if point is None:
|
||||
return
|
||||
with log.step("preflight.interrupted_attempt") as step:
|
||||
try:
|
||||
authority = SdkDataPlaneMigrationStore(database_path).authority()
|
||||
except Exception as error:
|
||||
# Never restore over a home that may already be on SDK authority.
|
||||
raise MigrationRestoreFailed("sdk_migration_restore_authority_unknown") from error
|
||||
step["authority"] = authority.value
|
||||
if authority is MemoryAuthority.SDK:
|
||||
clear_restore_point(data_dir)
|
||||
step["restored"] = False
|
||||
return
|
||||
restore_pre_migration_state(
|
||||
data_dir,
|
||||
bundle_id=point["bundle_id"],
|
||||
migration_id=point["migration_id"],
|
||||
reason="interrupted_attempt",
|
||||
)
|
||||
step["restored"] = True
|
||||
|
||||
|
||||
def _vacuum(database_path: Path) -> None:
|
||||
@@ -537,14 +618,28 @@ def run_startup_migration(
|
||||
namespace: str,
|
||||
progress: Callable[[str], None] = lambda _step: None,
|
||||
open_client: Callable[[ModelProfileSnapshot], AbstractContextManager[Any]] | None = None,
|
||||
restore_report: dict[str, object] | None = None,
|
||||
) -> StartupMigrationResult:
|
||||
"""Bring one stopped home to SDK authority. Safe to repeat after any failure.
|
||||
|
||||
The caller must stop Runtime and keep its service from restarting.
|
||||
`open_client` yields a public SDK client configured as the Runtime will be;
|
||||
tests replace it.
|
||||
|
||||
A failure before SDK authority is committed puts the home back exactly as
|
||||
this run found it (`sdk_migration_restore`), so the next run, possibly of a
|
||||
fixed release, starts from the original data. The original exception is
|
||||
re-raised either way; `restore_report` learns what the restore did
|
||||
(`state`: `restored`, `failed` with its own `code`, or `skipped`).
|
||||
"""
|
||||
from one_memory.sdk_migration_restore import read_restore_point, restore_pending
|
||||
|
||||
database_path = data_dir / "one-memory.sqlite3"
|
||||
log = _StepLog(data_dir)
|
||||
# Before anything reads the home: a run killed part-way is rolled back.
|
||||
if restore_pending(data_dir) or read_restore_point(data_dir) is not None:
|
||||
with capture_fence(data_dir):
|
||||
_recover_interrupted_attempt(data_dir, database_path, log)
|
||||
if not database_path.is_file():
|
||||
return StartupMigrationResult(state="not_required")
|
||||
|
||||
@@ -553,7 +648,6 @@ def run_startup_migration(
|
||||
return open_client(profile)
|
||||
return runtime_sdk_client(database_path, data_dir, profile)
|
||||
|
||||
log = _StepLog(data_dir)
|
||||
store = SdkDataPlaneMigrationStore(database_path)
|
||||
with log.step("preflight.authority_read") as step:
|
||||
authority, authority_migration = store.authority_state()
|
||||
@@ -613,6 +707,12 @@ def run_startup_migration(
|
||||
from one_memory.database_access import close_main_databases
|
||||
from one_memory.memory_server.migration_runner import SqliteMigrationStore, migrate
|
||||
from one_memory.memory_server.migrations import MIGRATIONS
|
||||
from one_memory.sdk_migration_restore import (
|
||||
clear_restore_point,
|
||||
discard_failed_state,
|
||||
prune_restored_bundles,
|
||||
record_restore_point,
|
||||
)
|
||||
|
||||
with log.step("preflight.resumable_identity") as step:
|
||||
migration_id, bundle_id = _resumable_identity(database_path)
|
||||
@@ -630,66 +730,105 @@ def run_startup_migration(
|
||||
raise DataPlaneMigrationError("sdk_migration_model_profile_required")
|
||||
log.check("preflight.model_profile_check", None)
|
||||
with capture_fence(data_dir):
|
||||
progress("bundle")
|
||||
with log.step("bundle.ensure") as step:
|
||||
manifest = _ensure_bundle(
|
||||
data_dir=data_dir,
|
||||
bundle_id=bundle_id,
|
||||
runtime_build=runtime_build,
|
||||
)
|
||||
step["bundle_id"] = bundle_id
|
||||
progress("prepare")
|
||||
with log.step("prepare.schema_migrate") as step:
|
||||
asyncio.run(migrate(SqliteMigrationStore(database_path), MIGRATIONS))
|
||||
close_main_databases()
|
||||
step["schema_version"] = MIGRATIONS[-1].version
|
||||
# Builds through 19 left a staged SDK copy beside the original store.
|
||||
# Settling it before the bundle means the bundle holds the original
|
||||
# store, and a restore never brings the staged copy back.
|
||||
with log.step("prepare.recover_abandoned_staging"):
|
||||
recover_abandoned_staging(
|
||||
data_dir=data_dir,
|
||||
database_path=database_path,
|
||||
namespace=namespace,
|
||||
)
|
||||
with log.step("prepare.ledger_start"):
|
||||
store.start(
|
||||
migration_id=migration_id,
|
||||
source_runtime_build=str(manifest.get("runtime_build") or ""),
|
||||
target_sdk_version=sdk_version,
|
||||
source_schema_version=MIGRATIONS[-1].version,
|
||||
namespace_fingerprint=hashlib.sha256(namespace.encode()).hexdigest(),
|
||||
backend_fingerprint=hashlib.sha256(b"chroma:kuzu:sqlite").hexdigest(),
|
||||
backup_bundle_id=bundle_id,
|
||||
progress("bundle")
|
||||
with log.step("bundle.ensure") as step:
|
||||
manifest, created = _ensure_bundle(
|
||||
data_dir=data_dir,
|
||||
bundle_id=bundle_id,
|
||||
runtime_build=runtime_build,
|
||||
)
|
||||
with log.step("prepare.legacy_work") as step:
|
||||
preparation = prepare_legacy_work_for_cutover(database_path, step)
|
||||
step.update(preparation.as_document())
|
||||
reattached = ()
|
||||
readback = None
|
||||
if profile is not None:
|
||||
progress("vectors")
|
||||
with log.step("vectors.reattach") as step:
|
||||
reattached = reattach_legacy_vectors(data_dir, profile)
|
||||
step["collections"] = len(reattached)
|
||||
if not legacy.empty:
|
||||
assert profile is not None
|
||||
progress("verify")
|
||||
with log.step("verify.readback") as step:
|
||||
with client_for(profile) as client:
|
||||
readback = verify_readback(client, legacy)
|
||||
step.update(readback.as_document())
|
||||
if not readback.passed:
|
||||
store.set_phase(migration_id, phase="readback_failed", state="blocked")
|
||||
raise MemoryReadbackFailed(readback)
|
||||
with log.step("prepare.mark_prepared"):
|
||||
store.set_phase(migration_id, phase=PREPARED_PHASE, state="ready")
|
||||
progress("cutover")
|
||||
with log.step("cutover.commit"):
|
||||
commit_sdk_cutover(
|
||||
step["bundle_id"] = bundle_id
|
||||
step["created"] = created
|
||||
with log.step("bundle.restore_point") as step:
|
||||
restore_bundle = _restore_point_bundle(
|
||||
data_dir=data_dir,
|
||||
migration_id=migration_id,
|
||||
bundle_id=bundle_id,
|
||||
created=created,
|
||||
runtime_build=runtime_build,
|
||||
)
|
||||
record_restore_point(data_dir, migration_id=migration_id, bundle_id=restore_bundle)
|
||||
step["bundle_id"] = restore_bundle
|
||||
step["pruned"] = prune_restored_bundles(data_dir, keep={bundle_id, restore_bundle})
|
||||
# From here until SDK authority is committed, a failure puts the
|
||||
# restore point back. Only `cutover.commit` can fail after its own
|
||||
# commit; the database decides then.
|
||||
cutover_started = False
|
||||
try:
|
||||
progress("prepare")
|
||||
with log.step("prepare.schema_migrate") as step:
|
||||
asyncio.run(migrate(SqliteMigrationStore(database_path), MIGRATIONS))
|
||||
close_main_databases()
|
||||
step["schema_version"] = MIGRATIONS[-1].version
|
||||
with log.step("prepare.ledger_start"):
|
||||
store.start(
|
||||
migration_id=migration_id,
|
||||
source_runtime_build=str(manifest.get("runtime_build") or ""),
|
||||
target_sdk_version=sdk_version,
|
||||
source_schema_version=MIGRATIONS[-1].version,
|
||||
namespace_fingerprint=hashlib.sha256(namespace.encode()).hexdigest(),
|
||||
backend_fingerprint=hashlib.sha256(b"chroma:kuzu:sqlite").hexdigest(),
|
||||
backup_bundle_id=bundle_id,
|
||||
)
|
||||
with log.step("prepare.legacy_work") as step:
|
||||
preparation = prepare_legacy_work_for_cutover(database_path, step)
|
||||
step.update(preparation.as_document())
|
||||
reattached = ()
|
||||
readback = None
|
||||
if profile is not None:
|
||||
progress("vectors")
|
||||
with log.step("vectors.reattach") as step:
|
||||
reattached = reattach_legacy_vectors(data_dir, profile)
|
||||
step["collections"] = len(reattached)
|
||||
if not legacy.empty:
|
||||
assert profile is not None
|
||||
progress("verify")
|
||||
with log.step("verify.readback") as step:
|
||||
with client_for(profile) as client:
|
||||
readback = verify_readback(client, legacy)
|
||||
step.update(readback.as_document())
|
||||
if not readback.passed:
|
||||
store.set_phase(migration_id, phase="readback_failed", state="blocked")
|
||||
raise MemoryReadbackFailed(readback)
|
||||
with log.step("prepare.mark_prepared"):
|
||||
store.set_phase(migration_id, phase=PREPARED_PHASE, state="ready")
|
||||
progress("cutover")
|
||||
cutover_started = True
|
||||
with log.step("cutover.commit"):
|
||||
commit_sdk_cutover(
|
||||
data_dir=data_dir,
|
||||
database_path=database_path,
|
||||
migration_id=migration_id,
|
||||
bundle_id=bundle_id,
|
||||
sdk_version=sdk_version,
|
||||
)
|
||||
except Exception:
|
||||
_restore_after_failure(
|
||||
data_dir=data_dir,
|
||||
database_path=database_path,
|
||||
migration_id=migration_id,
|
||||
bundle_id=bundle_id,
|
||||
sdk_version=sdk_version,
|
||||
bundle_id=restore_bundle,
|
||||
cutover_started=cutover_started,
|
||||
report=restore_report,
|
||||
)
|
||||
raise
|
||||
# SDK authority is committed: from here on the home is never restored.
|
||||
clear_restore_point(data_dir)
|
||||
if restore_bundle != bundle_id:
|
||||
shutil.rmtree(
|
||||
data_dir.resolve() / "backups" / f"{restore_bundle}.sdk-bundle",
|
||||
ignore_errors=True,
|
||||
)
|
||||
discard_failed_state(data_dir)
|
||||
progress("cleanup")
|
||||
with log.step("cleanup.drop_legacy_tables") as step:
|
||||
dropped = resume_legacy_cleanup(data_dir=data_dir, database_path=database_path)
|
||||
@@ -706,3 +845,52 @@ def run_startup_migration(
|
||||
reattached=reattached,
|
||||
readback=readback,
|
||||
)
|
||||
|
||||
|
||||
def _restore_after_failure(
|
||||
*,
|
||||
data_dir: Path,
|
||||
database_path: Path,
|
||||
migration_id: str,
|
||||
bundle_id: str,
|
||||
cutover_started: bool,
|
||||
report: dict[str, object] | None,
|
||||
) -> None:
|
||||
"""Put the restore point back after a pre-cutover failure. Never raises.
|
||||
|
||||
The caller re-raises the migration's own error: the restore is secondary,
|
||||
so its outcome only goes to the log and to `report`.
|
||||
"""
|
||||
from one_memory.sdk_migration_log import write_migration_log
|
||||
from one_memory.sdk_migration_restore import restore_pre_migration_state
|
||||
|
||||
report = {} if report is None else report
|
||||
if cutover_started:
|
||||
try:
|
||||
committed = SdkDataPlaneMigrationStore(database_path).authority() is MemoryAuthority.SDK
|
||||
except Exception: # noqa: BLE001 - never restore over an unknown authority
|
||||
committed = True
|
||||
if committed:
|
||||
write_migration_log(
|
||||
data_dir,
|
||||
writer="core-sdk-migrate",
|
||||
event="restore.skipped",
|
||||
outcome="succeeded",
|
||||
migration_id=migration_id,
|
||||
details={"reason": "authority_committed_or_unknown"},
|
||||
)
|
||||
report["state"] = "skipped"
|
||||
return
|
||||
try:
|
||||
restore_pre_migration_state(
|
||||
data_dir,
|
||||
bundle_id=bundle_id,
|
||||
migration_id=migration_id,
|
||||
reason="migration_failed",
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - the migration's own failure is raised
|
||||
code = getattr(error, "code", None)
|
||||
report["state"] = "failed"
|
||||
report["code"] = code if isinstance(code, str) else "sdk_migration_restore_failed"
|
||||
return
|
||||
report["state"] = "restored"
|
||||
@@ -152,13 +152,13 @@
|
||||
| `test_measure_c1_import_scale.py` | `contract` | The import measurement must not report fabricated or conflated counters. |
|
||||
| `test_measure_c1_loop_blocking_contract.py` | `integration` | Contract guards for the C1 event-loop blocking measurement. |
|
||||
| `test_memhub_display_identity.py` | `contract` | Display name MemHub must not fork the technical identity. |
|
||||
| `test_memory_additions.py` | `integration` | Add one memory directly: `POST /api/v2/memories` and `GET /api/v2/memories/additions/{id}`. |
|
||||
| `test_memory_batch_extraction.py` | `contract` | 覆盖 batch extractor keeps only unique known evidence ids、batch extractor maps provider failure to machine error 场景。 |
|
||||
| `test_memory_citation_query_plan.py` | `contract` | Correlated citation checks must look links up by memory, not scan by 'active'. |
|
||||
| `test_memory_content_import.py` | `integration` | 覆盖 existing memory preview keeps unknown author and exact original、invalid existing memory records never leave a partial upload、memory content requires explicit mode and rejects vendor guessing 等 5 个场景。 |
|
||||
| `test_memory_evidence_freshness.py` | `contract` | 新鲜度拆除后:读路径恒为 current;证据修订 / 畸形 proof / segment ref 不再藏记忆或 503。 |
|
||||
| `test_memory_freshness_integration_review.py` | `integration` | Independent synthetic read-path review; no lifespan, model or user database. |
|
||||
| `test_memory_library_all_scenes.py` | `integration` | The v2 memory library across chat, working and coding memories. |
|
||||
| `test_memory_notes.py` | `integration` | 覆盖 empty library can save a note without a matter or configured model、note requires authentication explicit scope and nonempty content、saved note evidence returns raw and consent reference from same record 等 6 个场景。 |
|
||||
| `test_memory_qa.py` | `contract` | 覆盖 search terms keep meaningful single chinese character、streamed answer emits recall then incremental grounded text、governance change during generation stops the old answer 等 14 个场景。 |
|
||||
| `test_memory_qa_api.py` | `integration` | 覆盖 ask stream endpoint emits recall deltas and final、ask stream endpoint requires the local token 场景。 |
|
||||
| `test_memory_qa_permissions.py` | `contract` | 覆盖 initial refusal precedes all secret access and network、invalid authorization result fails before secret access、authorization receives exact question and only outbound grounding 等 16 个场景。 |
|
||||
@@ -249,6 +249,7 @@
|
||||
| `test_sdk_memory_qa.py` | `contract` | 覆盖 sdk ask propagates sdk search failure without legacy fallback、sdk ask revalidates memory before returning extract、sdk ask uses native sdk memory without governance fields 场景。 |
|
||||
| `test_sdk_migration_bundle.py` | `contract` | SDK-authority cutover requires a verified management + SDK data-plane bundle. |
|
||||
| `test_sdk_migration_log.py` | `integration` | `sdk-migration.log` explains an unfinished upgrade, and a legacy home is never served. |
|
||||
| `test_sdk_migration_restore.py` | `contract` | A failed upgrade migration puts the home back exactly as it found it. |
|
||||
| `test_sdk_verbatim_fallback.py` | `contract` | D98: the SDK's verbatim fallback must stay off. |
|
||||
| `test_search_without_profile.py` | `contract` | What 'semantic_search' should do once the profile that filled the store is gone. |
|
||||
| `test_secondary_source_adapters.py` | `contract` | 覆盖 pi and qwenwork keep tool and sidechain content out of the persons words、qwen code reads only the transcript beside its bookkeeping、qwen native parts preserve visible text not reasoning or tools 等 22 个场景。 |
|
||||
@@ -306,4 +307,4 @@
|
||||
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
|
||||
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
|
||||
|
||||
共 301 个测试文件。
|
||||
共 302 个测试文件。
|
||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||
EXPECTED_TEST_FILE_COUNT = 301
|
||||
EXPECTED_TEST_FILES_SHA256 = "1e5f3a85f124a26ed67861e07fba7c670512a82139db64c1b7ba767ca37ed563"
|
||||
EXPECTED_TEST_FILE_COUNT = 302
|
||||
EXPECTED_TEST_FILES_SHA256 = "07707528025ac134e46c93ebef12335e852df1ed0f52f8beb62abb3b4d1b0e97"
|
||||
|
||||
# Pure transformations, validation models, parity checks and state machines.
|
||||
# This list is intentionally small: uncertain files default to contract.
|
||||
@@ -78,8 +78,8 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_measure_c1_loop_blocking_contract.py",
|
||||
"test_memory_content_import.py",
|
||||
"test_memory_freshness_integration_review.py",
|
||||
"test_memory_additions.py",
|
||||
"test_memory_library_all_scenes.py",
|
||||
"test_memory_notes.py",
|
||||
"test_memory_qa_api.py",
|
||||
"test_memory_server_durable_queue.py",
|
||||
"test_memory_supersede.py",
|
||||
|
||||
@@ -34,6 +34,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -201,10 +202,10 @@ def main() -> int:
|
||||
receipt["reason"] = "runtime_not_ready"
|
||||
return 2
|
||||
|
||||
_run(cli, ["init", "--source", "workbuddy", "--json"], env)
|
||||
plan = _json(_run(cli, ["setup", "plan", "--source", "workbuddy", "--json"], env).stdout)
|
||||
plan_file = work / "plan-inner.json"
|
||||
plan_file.write_text(json.dumps(plan.get("plan", {})), encoding="utf-8")
|
||||
_run(cli, ["sources", "connect", "workbuddy", "--json"], env)
|
||||
# No plan files: the connect request id is the idempotency key, minted
|
||||
# here so the receipt can be read while the import runs.
|
||||
plan = {"plan": {"request_id": str(uuid.uuid4())}}
|
||||
|
||||
idle_latencies = []
|
||||
idle_errors: list[int] = []
|
||||
@@ -217,7 +218,15 @@ def main() -> int:
|
||||
started = time.perf_counter()
|
||||
imported = _run(
|
||||
cli,
|
||||
["sources", "import", "--plan-file", str(plan_file), "--confirm"],
|
||||
[
|
||||
"sources",
|
||||
"connect",
|
||||
"workbuddy",
|
||||
"--import",
|
||||
"--request-id",
|
||||
plan["plan"]["request_id"],
|
||||
"--json",
|
||||
],
|
||||
env,
|
||||
)
|
||||
during_latencies = []
|
||||
|
||||
@@ -36,6 +36,7 @@ import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from subprocess import Popen
|
||||
|
||||
@@ -266,14 +267,22 @@ def _receipt(
|
||||
|
||||
|
||||
def _import_plan(cli: Path, env: dict[str, str], work: Path, *sources: str) -> dict:
|
||||
args = ["init"]
|
||||
for source in sources:
|
||||
args.extend(["--source", source])
|
||||
args.append("--json")
|
||||
plan = _body(_run(cli, args, env).stdout)
|
||||
# Grant only (processing stays frozen); the imports below connect again
|
||||
# with --import.
|
||||
plan = _body(_run(cli, ["sources", "connect", *sources, "--json"], env).stdout)
|
||||
return plan
|
||||
|
||||
|
||||
def _connect_plan(source: str) -> dict:
|
||||
"""The request a `sources connect --import` will carry.
|
||||
|
||||
There are no plan files any more: the CLI connects directly and the
|
||||
request id is the idempotency key, so the probe mints it up front to read
|
||||
the receipt afterwards.
|
||||
"""
|
||||
return {"plan": {"request_id": str(uuid.uuid4()), "source_ids": [source]}}
|
||||
|
||||
|
||||
def _submit_plan(
|
||||
cli: Path,
|
||||
env: dict[str, str],
|
||||
@@ -282,9 +291,20 @@ def _submit_plan(
|
||||
name: str,
|
||||
wait_timeout: float = CONVERGENCE_TIMEOUT_SECONDS,
|
||||
) -> dict:
|
||||
path = work / f"{name}-inner.json"
|
||||
path.write_text(json.dumps(plan.get("plan", {})), encoding="utf-8")
|
||||
result = _run(cli, ["sources", "import", "--plan-file", str(path), "--confirm"], env)
|
||||
body = plan.get("plan", {})
|
||||
result = _run(
|
||||
cli,
|
||||
[
|
||||
"sources",
|
||||
"connect",
|
||||
*body.get("source_ids", []),
|
||||
"--import",
|
||||
"--request-id",
|
||||
str(body.get("request_id")),
|
||||
"--json",
|
||||
],
|
||||
env,
|
||||
)
|
||||
return {
|
||||
"exit": result.returncode,
|
||||
"receipt": _receipt(cli, env, plan, wait_timeout=wait_timeout),
|
||||
@@ -394,10 +414,21 @@ def _preserve_run_state(dest: Path, data: Path, receipt: dict, args) -> dict:
|
||||
|
||||
def _busy_negative_arm(cli: Path, env: dict[str, str], work: Path, source: str) -> dict:
|
||||
"""Prove a concurrent history request is refused without a second job."""
|
||||
plan = _body(_run(cli, ["setup", "plan", "--source", source, "--json"], env).stdout)
|
||||
path = work / "busy-inner.json"
|
||||
path.write_text(json.dumps(plan.get("plan", {})), encoding="utf-8")
|
||||
result = _run(cli, ["sources", "import", "--plan-file", str(path), "--confirm"], env)
|
||||
plan = _connect_plan(source)
|
||||
body = plan["plan"]
|
||||
result = _run(
|
||||
cli,
|
||||
[
|
||||
"sources",
|
||||
"connect",
|
||||
source,
|
||||
"--import",
|
||||
"--request-id",
|
||||
body["request_id"],
|
||||
"--json",
|
||||
],
|
||||
env,
|
||||
)
|
||||
return {
|
||||
"exit": result.returncode,
|
||||
"receipt": _receipt(cli, env, plan, wait_terminal=False),
|
||||
@@ -408,7 +439,7 @@ def _run_busy_negative(
|
||||
cli: Path, env: dict[str, str], work: Path, source: str, timeout: float
|
||||
) -> int:
|
||||
"""A separate arm: make a real long-running job, then require busy/no-job."""
|
||||
first_plan = _body(_run(cli, ["setup", "plan", "--source", source, "--json"], env).stdout)
|
||||
first_plan = _connect_plan(source)
|
||||
first = _submit_plan(cli, env, work, first_plan, "busy-first", timeout)
|
||||
first_job = _nested(first, "receipt").get("jobs", [])
|
||||
if not first_job or first_job[0].get("status") not in {"running", "pending"}:
|
||||
@@ -583,22 +614,16 @@ def main() -> int:
|
||||
if args.busy_negative:
|
||||
return _run_busy_negative(cli, env, work, args.source, args.wait_timeout)
|
||||
|
||||
# Authorize first, then use the same headless plan shape for every
|
||||
# import. `init --json` is a discovery/authorization receipt and its
|
||||
# nested shape differs from `setup plan`.
|
||||
# Authorize first, then connect with --import for every import.
|
||||
_import_plan(cli, env, work, args.source)
|
||||
first_plan = _body(
|
||||
_run(cli, ["setup", "plan", "--source", args.source, "--json"], env).stdout
|
||||
)
|
||||
first_plan = _connect_plan(args.source)
|
||||
receipt["first"] = _submit_plan(cli, env, work, first_plan, "first", args.wait_timeout)
|
||||
if _nested(receipt, "first", "receipt").get("_probe_timeout"):
|
||||
receipt["state"] = "failed"
|
||||
receipt["reason"] = "convergence_timeout"
|
||||
return 1
|
||||
|
||||
second_plan = _body(
|
||||
_run(cli, ["setup", "plan", "--source", args.source, "--json"], env).stdout
|
||||
)
|
||||
second_plan = _connect_plan(args.source)
|
||||
receipt["second"] = _submit_plan(cli, env, work, second_plan, "second", args.wait_timeout)
|
||||
if _nested(receipt, "second", "receipt").get("_probe_timeout"):
|
||||
receipt["state"] = "failed"
|
||||
@@ -607,9 +632,7 @@ def main() -> int:
|
||||
|
||||
with copied[0].open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(_append_record(args.source)) + "\n")
|
||||
append_plan = _body(
|
||||
_run(cli, ["setup", "plan", "--source", args.source, "--json"], env).stdout
|
||||
)
|
||||
append_plan = _connect_plan(args.source)
|
||||
receipt["append"] = _submit_plan(cli, env, work, append_plan, "append", args.wait_timeout)
|
||||
if _nested(receipt, "append", "receipt").get("_probe_timeout"):
|
||||
receipt["state"] = "failed"
|
||||
@@ -631,9 +654,7 @@ def main() -> int:
|
||||
text=True,
|
||||
)
|
||||
receipt["restart"] = _wait_ready(cli, env, process)
|
||||
restart_plan = _body(
|
||||
_run(cli, ["setup", "plan", "--source", args.source, "--json"], env).stdout
|
||||
)
|
||||
restart_plan = _connect_plan(args.source)
|
||||
receipt["restart_import"] = _submit_plan(
|
||||
cli, env, work, restart_plan, "restart", args.wait_timeout
|
||||
)
|
||||
@@ -642,18 +663,10 @@ def main() -> int:
|
||||
receipt["reason"] = "convergence_timeout"
|
||||
return 1
|
||||
|
||||
freeze_plan = _body(
|
||||
_run(
|
||||
cli,
|
||||
["sync", "pause", "--source", args.source, "--json"],
|
||||
env,
|
||||
).stdout
|
||||
)
|
||||
freeze_file = work / "freeze-inner.json"
|
||||
freeze_file.write_text(json.dumps(freeze_plan.get("plan", {})), encoding="utf-8")
|
||||
# `sync pause` applies directly (there is no plan/apply pair).
|
||||
applied = _run(
|
||||
cli,
|
||||
["sync", "apply", "--plan-file", str(freeze_file), "--confirm"],
|
||||
["sync", "pause", "--source", args.source, "--json"],
|
||||
env,
|
||||
)
|
||||
listed = _body(_run(cli, ["sources", "list", "--json"], env).stdout)
|
||||
|
||||
@@ -200,6 +200,7 @@ def test_sdk_migrate_failure_is_one_named_code(tmp_path, monkeypatch) -> None:
|
||||
"schema_version": 1,
|
||||
"state": "failed",
|
||||
"code": "sdk_migration_disk_space_insufficient",
|
||||
"data_restored": False,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+198
-35
@@ -1,3 +1,5 @@
|
||||
"""Add one memory directly: `POST /api/v2/memories` and `GET /api/v2/memories/additions/{id}`."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
@@ -20,7 +22,7 @@ def application(tmp_path):
|
||||
Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path,
|
||||
api_token=SecretStr("note-test"),
|
||||
api_token=SecretStr("memory-add-test"),
|
||||
imports_enabled=False,
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
@@ -39,56 +41,59 @@ def client_for(app):
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer note-test"},
|
||||
headers={"Authorization": "Bearer memory-add-test"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_empty_library_can_save_a_note_without_a_matter_or_configured_model(tmp_path):
|
||||
def test_empty_library_can_add_a_memory_without_a_matter_or_configured_model(tmp_path):
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
assert legacy_tables_present(app.state.settings.data_dir / "one-memory.sqlite3") == ()
|
||||
note = {
|
||||
addition = {
|
||||
"request_id": str(uuid4()),
|
||||
"text": "请记住:项目周会不排在周五。",
|
||||
"scope": "all-apps",
|
||||
}
|
||||
response = await client.post("/api/v1/memory-notes", json=note)
|
||||
response = await client.post("/api/v2/memories", json=addition)
|
||||
assert response.status_code == 201
|
||||
saved = response.json()
|
||||
assert saved["state"] == "pending"
|
||||
assert saved["memory_ids"] == []
|
||||
record = app.state.memory_note_store.require(saved["evidence_id"])
|
||||
record = app.state.memory_addition_store.require(saved["evidence_id"])
|
||||
assert record.source_id == "one-memory-manual"
|
||||
assert record.role == "user" and record.written_by == WrittenBy.PERSON
|
||||
assert record.verbatim == note["text"] and record.created_at is not None
|
||||
again = await client.post("/api/v1/memory-notes", json=note)
|
||||
assert record.verbatim == addition["text"] and record.created_at is not None
|
||||
again = await client.post("/api/v2/memories", json=addition)
|
||||
assert again.json()["evidence_id"] == saved["evidence_id"]
|
||||
operation = await client.get(f"/api/v1/memory-notes/operations/{note['request_id']}")
|
||||
assert operation.status_code == 200
|
||||
assert operation.json()["evidence_id"] == saved["evidence_id"]
|
||||
assert (
|
||||
await client.get(f"/api/v1/memory-notes/operations/{uuid4()}")
|
||||
).status_code == 404
|
||||
status = await client.get(f"/api/v2/memories/additions/{addition['request_id']}")
|
||||
assert status.status_code == 200
|
||||
assert status.json()["evidence_id"] == saved["evidence_id"]
|
||||
missing = await client.get(f"/api/v2/memories/additions/{uuid4()}")
|
||||
assert missing.status_code == 404
|
||||
assert missing.json()["detail"] == "memory_add_not_found"
|
||||
conflict = await client.post(
|
||||
"/api/v1/memory-notes", json={**note, "text": "Changed content"}
|
||||
"/api/v2/memories", json={**addition, "text": "Changed content"}
|
||||
)
|
||||
assert conflict.status_code == 409
|
||||
assert conflict.json()["detail"] == "memory_add_conflict"
|
||||
assert (
|
||||
app.state.memory_note_store.require(saved["evidence_id"]).verbatim == note["text"]
|
||||
app.state.memory_addition_store.require(saved["evidence_id"]).verbatim
|
||||
== addition["text"]
|
||||
)
|
||||
assert legacy_tables_present(app.state.settings.data_dir / "one-memory.sqlite3") == ()
|
||||
restarted = application(tmp_path)
|
||||
async with restarted.router.lifespan_context(restarted), client_for(restarted) as client:
|
||||
notes = (await client.get("/api/v1/memory-notes")).json()
|
||||
assert len(notes) == 1 and notes[0]["evidence_id"] == saved["evidence_id"]
|
||||
status = await client.get(f"/api/v2/memories/additions/{addition['request_id']}")
|
||||
assert status.status_code == 200
|
||||
assert status.json()["evidence_id"] == saved["evidence_id"]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_note_requires_authentication_explicit_scope_and_nonempty_content(tmp_path):
|
||||
def test_memory_add_requires_authentication_explicit_scope_and_nonempty_content(tmp_path):
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
@@ -100,23 +105,26 @@ def test_note_requires_authentication_explicit_scope_and_nonempty_content(tmp_pa
|
||||
{"user_id": "other"},
|
||||
):
|
||||
assert (
|
||||
await client.post("/api/v1/memory-notes", json={**base, **change})
|
||||
await client.post("/api/v2/memories", json={**base, **change})
|
||||
).status_code == 422
|
||||
assert (
|
||||
await client.post(
|
||||
"/api/v1/memory-notes",
|
||||
"/api/v2/memories",
|
||||
json={"request_id": base["request_id"], "text": "No scope"},
|
||||
)
|
||||
).status_code == 422
|
||||
client.headers.pop("Authorization")
|
||||
assert (await client.post("/api/v1/memory-notes", json=base)).status_code == 401
|
||||
assert app.state.memory_note_store.list() == []
|
||||
assert (await client.post("/api/v2/memories", json=base)).status_code == 401
|
||||
status_path = f"/api/v2/memories/additions/{base['request_id']}"
|
||||
assert (await client.get(status_path)).status_code == 401
|
||||
client.headers["Authorization"] = "Bearer memory-add-test"
|
||||
assert (await client.get(status_path)).status_code == 404
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_note_and_source_grant_are_atomic(tmp_path):
|
||||
from one_memory.memory_notes import MemoryNoteInput
|
||||
def test_memory_add_and_source_grant_are_atomic(tmp_path):
|
||||
from one_memory.memory_additions import MemoryAddInput
|
||||
|
||||
async def scenario():
|
||||
app = legacy_application(tmp_path)
|
||||
@@ -127,8 +135,8 @@ def test_note_and_source_grant_are_atomic(tmp_path):
|
||||
"BEGIN SELECT RAISE(ABORT, 'grant failed'); END"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="grant failed"):
|
||||
app.state.memory_note_store.save(
|
||||
MemoryNoteInput(
|
||||
app.state.memory_addition_store.save(
|
||||
MemoryAddInput(
|
||||
request_id=uuid4(), text="Do not leave half a save.", scope="all-apps"
|
||||
)
|
||||
)
|
||||
@@ -137,22 +145,23 @@ def test_note_and_source_grant_are_atomic(tmp_path):
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_revoked_note_source_is_not_silently_reauthorized(tmp_path):
|
||||
def test_revoked_manual_source_is_not_silently_reauthorized(tmp_path):
|
||||
async def scenario():
|
||||
app = legacy_application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
note = {"request_id": str(uuid4()), "text": "Remember this.", "scope": "all-apps"}
|
||||
saved = (await client.post("/api/v1/memory-notes", json=note)).json()
|
||||
addition = {"request_id": str(uuid4()), "text": "Remember this.", "scope": "all-apps"}
|
||||
assert (await client.post("/api/v2/memories", json=addition)).status_code == 201
|
||||
with sqlite3.connect(app.state.evidence_store.database_path) as db:
|
||||
db.execute(
|
||||
"UPDATE source_grants SET revoked_at='2026-09-12' "
|
||||
"WHERE source_id='one-memory-manual'"
|
||||
)
|
||||
assert (await client.post("/api/v1/memory-notes", json=note)).status_code == 409
|
||||
assert (
|
||||
await client.post(f"/api/v1/memory-notes/{saved['evidence_id']}/process", json={})
|
||||
).status_code == 409
|
||||
assert (await client.get("/api/v1/memory-notes")).json() == []
|
||||
again = await client.post("/api/v2/memories", json=addition)
|
||||
assert again.status_code == 409
|
||||
assert again.json()["detail"] == "memory_add_source_revoked"
|
||||
status = await client.get(f"/api/v2/memories/additions/{addition['request_id']}")
|
||||
assert status.status_code == 409
|
||||
assert status.json()["detail"] == "memory_add_source_revoked"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -473,3 +482,157 @@ def test_sdk_context_search_sets_each_recall_lane_apart(tmp_path):
|
||||
] == [(10, 3, 1), (5, 0, 2)]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def _envelope(tools=("om_recall", "om_ask")) -> dict[str, str]:
|
||||
return {
|
||||
"x-one-memory-read-context": "external",
|
||||
"x-one-memory-installation-id": "00000000-0000-0000-0000-000000000001",
|
||||
"x-one-memory-installation-revision": "1",
|
||||
"x-one-memory-allowed-scopes": json.dumps(["all-apps"]),
|
||||
"x-one-memory-allowed-tools": json.dumps(list(tools)),
|
||||
}
|
||||
|
||||
|
||||
def _bare_request(method: str, path: str, headers: dict[str, str]):
|
||||
from starlette.requests import Request
|
||||
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": path,
|
||||
"headers": [(k.encode(), v.encode()) for k, v in headers.items()],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
[
|
||||
("POST", "/api/v2/memories"),
|
||||
("GET", f"/api/v2/memories/additions/{uuid4()}"),
|
||||
],
|
||||
)
|
||||
def test_memory_add_routes_are_denied_to_enveloped_requests(method, path):
|
||||
"""The broker writes as a trusted local caller; an external envelope never
|
||||
reaches the add or status route, whatever tools it carries."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from one_memory.external_memory_access import TOOLS, require_external_endpoint
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
require_external_endpoint(_bare_request(method, path, _envelope(sorted(TOOLS))))
|
||||
assert (error.value.status_code, error.value.detail) == (403, "external_endpoint_denied")
|
||||
# A trusted local caller (no envelope) is not refused by this gate.
|
||||
require_external_endpoint(_bare_request(method, path, {}))
|
||||
|
||||
|
||||
def test_existing_memories_broker_routes_keep_their_access_rules():
|
||||
"""Adding `POST /api/v2/memories` neither opens nor closes the ask routes."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from one_memory.external_memory_access import require_external_endpoint
|
||||
|
||||
for path in ("/api/v2/memories/ask", "/api/v2/memories/ask/stream"):
|
||||
require_external_endpoint(_bare_request("POST", path, _envelope(("om_ask",))))
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
require_external_endpoint(_bare_request("POST", path, _envelope(("om_recall",))))
|
||||
assert denied.value.detail == "external_recall_scope_denied"
|
||||
for method, path in (
|
||||
("GET", "/api/v2/memories/some-memory"),
|
||||
("GET", "/api/v2/memories/graph"),
|
||||
("PUT", "/api/v2/memories/some-memory"),
|
||||
("DELETE", "/api/v2/memories/some-memory"),
|
||||
):
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
require_external_endpoint(_bare_request(method, path, _envelope(("om_ask",))))
|
||||
assert denied.value.detail == "external_endpoint_denied"
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_enveloped_memory_add_saves_nothing(tmp_path):
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
addition = {"request_id": str(uuid4()), "text": "Remember this.", "scope": "all-apps"}
|
||||
refused = await client.post("/api/v2/memories", json=addition, headers=_envelope())
|
||||
assert refused.status_code == 403
|
||||
assert refused.json()["detail"] == "external_endpoint_denied"
|
||||
status = f"/api/v2/memories/additions/{addition['request_id']}"
|
||||
assert (await client.get(status, headers=_envelope())).status_code == 403
|
||||
assert (await client.get(status)).status_code == 404
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_memory_add_routes_do_not_collide_with_single_memory_routes(tmp_path):
|
||||
"""`/additions/{request_id}` is two segments and the add is a POST on the
|
||||
collection: neither may be served by `/api/v2/memories/{memory_id}`."""
|
||||
sdk_calls: list[tuple[str, object]] = []
|
||||
|
||||
class Client:
|
||||
def __getattr__(self, name):
|
||||
def record(*args, **kwargs):
|
||||
sdk_calls.append((name, args))
|
||||
return None
|
||||
|
||||
return record
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return Client()
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
request_id = str(uuid4())
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
app.state.sdk_client_handle = Handle()
|
||||
missing = await client.get(f"/api/v2/memories/additions/{request_id}")
|
||||
assert missing.status_code == 404
|
||||
assert missing.json()["detail"] == "memory_add_not_found"
|
||||
created = await client.post(
|
||||
"/api/v2/memories",
|
||||
json={"request_id": request_id, "text": "Remember this.", "scope": "all-apps"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert set(created.json()) == {
|
||||
"evidence_id",
|
||||
"text",
|
||||
"scope",
|
||||
"created_at",
|
||||
"state",
|
||||
"task_id",
|
||||
"memory_ids",
|
||||
"error",
|
||||
}
|
||||
status = await client.get(f"/api/v2/memories/additions/{request_id}")
|
||||
assert status.json()["evidence_id"] == created.json()["evidence_id"]
|
||||
# Neither route reached the single-memory SDK reads ...
|
||||
assert not [call for call in sdk_calls if call[0] == "get"]
|
||||
# ... while a single segment still does, even one named `additions`.
|
||||
await client.get("/api/v2/memories/additions")
|
||||
await client.get("/api/v2/memories/some-memory")
|
||||
assert [call[1][0] for call in sdk_calls if call[0] == "get"] == [
|
||||
"additions",
|
||||
"some-memory",
|
||||
]
|
||||
# The old note surface is gone.
|
||||
assert (await client.get("/api/v1/memory-notes")).status_code == 404
|
||||
assert (
|
||||
await client.post(
|
||||
"/api/v1/memory-notes",
|
||||
json={"request_id": str(uuid4()), "text": "x", "scope": "all-apps"},
|
||||
)
|
||||
).status_code == 404
|
||||
schema = (await client.get("/openapi.json")).json()
|
||||
assert "MemoryAddReceipt" in schema["components"]["schemas"]
|
||||
assert "MemoryAddInput" in schema["components"]["schemas"]
|
||||
assert not [name for name in schema["components"]["schemas"] if "Note" in name]
|
||||
assert not [path for path in schema["paths"] if "note" in path]
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -165,8 +165,40 @@ class FakeSdk:
|
||||
|
||||
def list_memories(self, **kwargs):
|
||||
self.list_calls.append(kwargs)
|
||||
assert kwargs["scene"] == "all"
|
||||
include = kwargs.get("include_sources") or []
|
||||
scene = kwargs["scene"]
|
||||
start, stop = kwargs["offset"], kwargs["offset"] + kwargs["limit"]
|
||||
if scene == "chat":
|
||||
rows = [row for row in self.chat.values() if not include or row["source"] in include]
|
||||
return {
|
||||
"vdb": {
|
||||
"memories": rows[start:stop],
|
||||
"total": len(rows),
|
||||
"limit": kwargs["limit"],
|
||||
"offset": kwargs["offset"],
|
||||
},
|
||||
"elapsed_ms": 1.0,
|
||||
}
|
||||
if scene in ("working", "coding"):
|
||||
rows = [
|
||||
{key: value for key, value in row.items() if not key.startswith("_")}
|
||||
| {"status": "active"}
|
||||
for (kind, variant, _id), row in self.productivity.items()
|
||||
if kind == "experience"
|
||||
and variant == scene
|
||||
and (not include or row["source"] in include)
|
||||
]
|
||||
return {
|
||||
"request_id": "list",
|
||||
"scene": "productivity",
|
||||
"variant": scene,
|
||||
"experiences": rows[start:stop],
|
||||
"total": len(rows),
|
||||
"limit": kwargs["limit"],
|
||||
"offset": kwargs["offset"],
|
||||
"elapsed_ms": 1.0,
|
||||
}
|
||||
assert scene == "all"
|
||||
chat = [
|
||||
{**row, "scene": "chat", "kind": "chat", "created_at": 10, "updated_at": 10}
|
||||
for row in self.chat.values()
|
||||
@@ -549,3 +581,69 @@ def test_ambiguous_memory_id_is_a_conflict(tmp_path, monkeypatch):
|
||||
assert resolved.json()["content"] == "chat text"
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_scene_filter_lists_one_scene(tmp_path, monkeypatch):
|
||||
async def scenario(app, sdk, client):
|
||||
sdk.add_chat(chat_row("chat-1", "chat text"))
|
||||
sdk.add_productivity("experience", experience_row("work-1", "w task", "w fix"))
|
||||
sdk.add_productivity(
|
||||
"experience", experience_row("code-1", "c task", "c fix", variant="coding")
|
||||
)
|
||||
|
||||
working = await client.get("/api/v2/memory-library", params={"scene": "working"})
|
||||
assert working.status_code == 200
|
||||
body = working.json()
|
||||
assert body["total"] == 1
|
||||
assert [item["memory_id"] for item in body["memories"]] == ["work-1"]
|
||||
assert body["memories"][0]["kind"] == "experience"
|
||||
assert body["memories"][0]["variant"] == "working"
|
||||
assert body["memories"][0]["scene"] == "working"
|
||||
assert sdk.list_calls[-1]["scene"] == "working"
|
||||
|
||||
chat = await client.get("/api/v2/memory-library", params={"scene": "chat"})
|
||||
assert chat.status_code == 200
|
||||
assert [item["memory_id"] for item in chat.json()["memories"]] == ["chat-1"]
|
||||
assert sdk.list_calls[-1]["scene"] == "chat"
|
||||
|
||||
default = await client.get("/api/v2/memory-library")
|
||||
assert default.status_code == 200
|
||||
assert sdk.list_calls[-1]["scene"] == "all"
|
||||
|
||||
bad = await client.get("/api/v2/memory-library", params={"scene": "normal"})
|
||||
assert bad.status_code == 422
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_scene_filter_narrows_search_and_binds_the_window(tmp_path, monkeypatch):
|
||||
async def scenario(app, sdk, client):
|
||||
sdk.add_chat(chat_row("chat-1", "deploy chat"))
|
||||
sdk.add_productivity("experience", experience_row("work-1", "deploy task", "run make"))
|
||||
sdk.add_productivity(
|
||||
"experience",
|
||||
experience_row("code-1", "deploy code", "run tests", variant="coding"),
|
||||
)
|
||||
|
||||
coding = await client.get(
|
||||
"/api/v2/memory-library", params={"query": "deploy", "scene": "coding"}
|
||||
)
|
||||
assert coding.status_code == 200
|
||||
body = coding.json()
|
||||
assert body["total"] == 1
|
||||
assert [item["memory_id"] for item in body["memories"]] == ["code-1"]
|
||||
assert sdk.search_calls[-1][1]["scene"] == "all"
|
||||
|
||||
# The same window under another scene is a different search.
|
||||
other = await client.get(
|
||||
"/api/v2/memory-library",
|
||||
params={"query": "deploy", "scene": "chat", "window_id": body["window_id"]},
|
||||
)
|
||||
assert other.status_code == 200
|
||||
assert other.json()["window_reset"] is True
|
||||
assert [item["memory_id"] for item in other.json()["memories"]] == ["chat-1"]
|
||||
assert len(sdk.search_calls) == 2
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
@@ -963,7 +963,7 @@ class _SdkPublicClient:
|
||||
def add_extracted(self, contents, **kwargs):
|
||||
self.events.append("add_extracted")
|
||||
self.calls.append(("add_extracted", contents, kwargs))
|
||||
return {"success": True, "memory_ids": ["memory-note"]}
|
||||
return {"success": True, "memory_ids": ["memory-added"]}
|
||||
|
||||
def get_write_outcome(self, request_id):
|
||||
self.calls.append(("get_write_outcome", request_id, {}))
|
||||
@@ -988,7 +988,7 @@ def _sdk_evidence(
|
||||
text: str = "hello",
|
||||
memory_content: bool = False,
|
||||
source: str = "cursor",
|
||||
manual_note: bool = False,
|
||||
manual_addition: bool = False,
|
||||
created_at: datetime | None = None,
|
||||
):
|
||||
from one_memory.imports.models import (
|
||||
@@ -1002,7 +1002,7 @@ def _sdk_evidence(
|
||||
|
||||
origin = (
|
||||
OriginMetadata(raw_meta={"input_mode": "direct_memory_note"})
|
||||
if manual_note
|
||||
if manual_addition
|
||||
else OriginMetadata()
|
||||
)
|
||||
return materialize_write_input(
|
||||
@@ -1089,14 +1089,14 @@ def test_sdk_write_processor_calls_add_once_with_public_scope_and_receipt() -> N
|
||||
(
|
||||
_sdk_evidence("memory", text="remember this", memory_content=True),
|
||||
_sdk_evidence(
|
||||
"note",
|
||||
text="save this note",
|
||||
"addition",
|
||||
text="save this memory",
|
||||
source="one-memory-manual",
|
||||
manual_note=True,
|
||||
manual_addition=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_notes(record) -> None:
|
||||
def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_additions(record) -> None:
|
||||
from one_memory.sdk_write_processor import SdkWriteProcessor, SdkWriteReceipt
|
||||
|
||||
client = _SdkPublicClient()
|
||||
@@ -1112,7 +1112,7 @@ def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_notes(rec
|
||||
before_hy_invocation=before,
|
||||
)
|
||||
|
||||
assert asyncio.run(run()) == SdkWriteReceipt(memory_ids=("memory-note",))
|
||||
assert asyncio.run(run()) == SdkWriteReceipt(memory_ids=("memory-added",))
|
||||
assert client.events == ["before", "add_extracted"]
|
||||
assert client.calls[0][1] == [record.verbatim]
|
||||
assert len(client.calls) == 1
|
||||
|
||||
@@ -301,16 +301,20 @@ def test_the_runner_names_the_step_and_sub_step_that_failed(tmp_path, monkeypatc
|
||||
for line in written
|
||||
if "step" in line.get("details", {})
|
||||
]
|
||||
assert steps[:6] == [
|
||||
assert steps[:8] == [
|
||||
("step.finished", "preflight.authority_read"),
|
||||
("step.finished", "preflight.profile_read"),
|
||||
("step.finished", "preflight.resumable_identity"),
|
||||
("step.finished", "preflight.legacy_ids_scan"),
|
||||
("step.finished", "preflight.model_profile_check"),
|
||||
("step.finished", "prepare.recover_abandoned_staging"),
|
||||
("step.finished", "bundle.ensure"),
|
||||
("step.finished", "bundle.restore_point"),
|
||||
]
|
||||
assert steps[-1] == ("step.failed", "prepare.legacy_work")
|
||||
failed = written[-1]
|
||||
# The failure put the home back; the restore says so after the failed step.
|
||||
assert [line["event"] for line in written[-2:]] == ["restore.started", "restore.finished"]
|
||||
failed = next(line for line in reversed(written) if line["event"] == "step.failed")
|
||||
assert failed["migration_id"].startswith("sdk-authority-v2-")
|
||||
assert failed["details"]["sub_step"] == "write_queue"
|
||||
assert failed["error"]["chain"][0]["type"] == "ValueError"
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""A failed upgrade migration puts the home back exactly as it found it.
|
||||
|
||||
The fixed release that follows must migrate from the original 1.0.4 data, not
|
||||
from a half-migrated home: every pre-cutover failure restores the database,
|
||||
the SDK store and `config.json`, and a later run succeeds from there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import closing, contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from one_memory import cli
|
||||
from one_memory.config import Settings
|
||||
from one_memory.database_access import close_main_databases, main_database
|
||||
from one_memory.imports.storage import EvidenceStore
|
||||
from one_memory.live_capture import sdk_migration_capture_fenced
|
||||
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 (
|
||||
DataPlaneMigrationError,
|
||||
MemoryAuthority,
|
||||
SdkDataPlaneMigrationStore,
|
||||
)
|
||||
from one_memory.sdk_migration_log import log_path
|
||||
from one_memory.sdk_migration_restore import (
|
||||
RESTORE_POINT,
|
||||
MigrationRestoreFailed,
|
||||
failed_state_path,
|
||||
finish_interrupted_restore,
|
||||
restore_pending,
|
||||
)
|
||||
from one_memory.sdk_migration_runner import run_startup_migration
|
||||
from one_memory.sdk_migration_verify import MemoryReadbackFailed
|
||||
from one_memory.target_schema import legacy_tables_present
|
||||
|
||||
NAMESPACE = "governed-v2"
|
||||
CONFIG = {"schema_version": 1, "sources": {"cursor": {"sync_enabled": True}}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profile(monkeypatch):
|
||||
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
|
||||
|
||||
snapshot = builtin_retrieval_snapshot()
|
||||
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: snapshot)
|
||||
monkeypatch.setattr("one_memory.sdk_migration_verify.read_active_profile", lambda _db: snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
def _chroma(home: Path):
|
||||
from one_memory.sdk_vector_reattach import open_offline_chroma
|
||||
|
||||
return open_offline_chroma(home / "hy-memory" / NAMESPACE / "vector")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home(tmp_path, profile) -> Path:
|
||||
"""A 1.0.4-shaped home: legacy memories, queued legacy work, legacy vectors."""
|
||||
from one_memory.hy_sdk_runtime import vector_collection_bases
|
||||
|
||||
database = tmp_path / "one-memory.sqlite3"
|
||||
asyncio.run(migrate(SqliteMigrationStore(database), MIGRATIONS))
|
||||
with main_database(database).write() as connection, connection:
|
||||
for memory_id in ("m1", "m2"):
|
||||
connection.execute(
|
||||
"INSERT INTO governed_memories (memory_id,user_id,kind,status,content,"
|
||||
"scopes_json,revision,created_at,updated_at) "
|
||||
"VALUES (?,'local','chat','confirmed','x','[]',1,'now','now')",
|
||||
(memory_id,),
|
||||
)
|
||||
verbatim = "text of e1"
|
||||
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 ('e1','e1','local','cursor','session-1','raw_conversation','user',"
|
||||
"'person',?,?,1,'2026-10-22T00:00:00+00:00','2026-10-22T00:00:00+00:00',1)",
|
||||
(verbatim, hashlib.sha256(verbatim.encode()).hexdigest()),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO memory_tasks "
|
||||
"(task_id,request_id,user_id,kind,input_ref,status,profile_id,"
|
||||
"profile_revision,profile_snapshot_json,runner_token,attempts,"
|
||||
"last_error_code,created_at,updated_at,duration_seconds,hy_invocation_count) "
|
||||
"VALUES ('t1','request-t1','local','extract','e1','pending','profile',1,'{}',"
|
||||
"NULL,0,NULL,'now','now',NULL,0)"
|
||||
)
|
||||
close_main_databases()
|
||||
assert EvidenceStore(database).get("e1") is not None
|
||||
close_main_databases()
|
||||
current, legacy = vector_collection_bases(profile)
|
||||
client = _chroma(tmp_path)
|
||||
for name, ids in ((f"{legacy[0]}_4", ["m1", "m2"]), (f"{current}_4", ["after"])):
|
||||
collection = client.get_or_create_collection(name, metadata={"hnsw:space": "cosine"})
|
||||
collection.upsert(
|
||||
ids=ids,
|
||||
embeddings=[[float(index + 1)] * 4 for index, _ in enumerate(ids)],
|
||||
documents=[f"doc {identifier}" for identifier in ids],
|
||||
)
|
||||
client.close()
|
||||
(tmp_path / "config.json").write_text(json.dumps(CONFIG), encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _collections(home: Path) -> dict[str, int]:
|
||||
client = _chroma(home)
|
||||
try:
|
||||
return {item.name: item.count() for item in client.list_collections()}
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _state(home: Path) -> dict[str, object]:
|
||||
"""Everything a migration may change, compared byte for byte or row for row."""
|
||||
close_main_databases()
|
||||
with closing(sqlite3.connect(home / "one-memory.sqlite3")) as connection:
|
||||
dump = list(connection.iterdump())
|
||||
sdk = home / "hy-memory" / NAMESPACE
|
||||
files = {
|
||||
path.relative_to(sdk).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
for path in sorted(sdk.rglob("*"))
|
||||
if path.is_file()
|
||||
}
|
||||
from one_memory.sdk_vector_reattach import _stored_names
|
||||
|
||||
return {
|
||||
"database": dump,
|
||||
"sdk_files": files,
|
||||
"collection_names": _stored_names(home),
|
||||
"config": (home / "config.json").read_bytes(),
|
||||
}
|
||||
|
||||
|
||||
def _reading(readable: set[str]):
|
||||
class Client:
|
||||
def list_memories(self, *, user_id, statuses, limit, offset, order):
|
||||
del user_id, statuses, order
|
||||
ids = sorted(readable)
|
||||
rows = [{"memory_id": item} for item in ids[offset : offset + limit]]
|
||||
return {"vdb": {"memories": rows, "total": len(ids)}}
|
||||
|
||||
def get(self, memory_id):
|
||||
return {"memory_id": memory_id} if memory_id in readable else None
|
||||
|
||||
@contextmanager
|
||||
def open_client(_profile):
|
||||
yield Client()
|
||||
|
||||
return open_client
|
||||
|
||||
|
||||
def _run(home: Path, *, readable=frozenset({"m1", "m2"}), report=None):
|
||||
return run_startup_migration(
|
||||
data_dir=home,
|
||||
runtime_build="22",
|
||||
sdk_version="2.0.5",
|
||||
namespace=NAMESPACE,
|
||||
open_client=_reading(set(readable)),
|
||||
restore_report=report,
|
||||
)
|
||||
|
||||
|
||||
def _events(home: Path) -> list[dict[str, object]]:
|
||||
return [json.loads(line) for line in log_path(home).read_text().splitlines()]
|
||||
|
||||
|
||||
def _fail_schema_migrate(monkeypatch, home):
|
||||
import one_memory.memory_server.migration_runner as runner_module
|
||||
|
||||
async def half_migrated(store, migrations):
|
||||
del store, migrations
|
||||
with main_database(home / "one-memory.sqlite3").write() as connection, connection:
|
||||
connection.execute("CREATE TABLE half_applied_migration (x INTEGER)")
|
||||
raise DataPlaneMigrationError("sdk_migration_schema_failed")
|
||||
|
||||
monkeypatch.setattr(runner_module, "migrate", half_migrated)
|
||||
|
||||
|
||||
def _fail_legacy_work(monkeypatch, home):
|
||||
import one_memory.sdk_migration_runner as runner
|
||||
|
||||
def broken(_database_path):
|
||||
raise DataPlaneMigrationError("sdk_data_plane_dispatch_failed")
|
||||
|
||||
monkeypatch.setattr(runner, "move_pending_dispatches", broken)
|
||||
|
||||
|
||||
def _fail_vectors(monkeypatch, home):
|
||||
import one_memory.sdk_migration_runner as runner
|
||||
|
||||
real = runner.reattach_legacy_vectors
|
||||
|
||||
def renamed_then_failed(data_dir, profile):
|
||||
real(data_dir, profile)
|
||||
raise DataPlaneMigrationError("sdk_migration_vector_reattach_incomplete")
|
||||
|
||||
monkeypatch.setattr(runner, "reattach_legacy_vectors", renamed_then_failed)
|
||||
|
||||
|
||||
FAILURES = {
|
||||
"prepare.schema_migrate": (_fail_schema_migrate, "sdk_migration_schema_failed"),
|
||||
"prepare.legacy_work": (_fail_legacy_work, "sdk_data_plane_dispatch_failed"),
|
||||
"vectors.reattach": (_fail_vectors, "sdk_migration_vector_reattach_incomplete"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("step", sorted(FAILURES))
|
||||
def test_a_failure_before_cutover_restores_the_home_exactly(home, monkeypatch, step):
|
||||
counts = _collections(home)
|
||||
before = _state(home)
|
||||
inject, code = FAILURES[step]
|
||||
inject(monkeypatch, home)
|
||||
report: dict[str, object] = {}
|
||||
|
||||
with pytest.raises(DataPlaneMigrationError) as caught:
|
||||
_run(home, report=report)
|
||||
|
||||
# The migration's own failure is what the caller sees.
|
||||
assert caught.value.code == code
|
||||
assert report == {"state": "restored"}
|
||||
assert _state(home) == before
|
||||
assert _collections(home) == counts
|
||||
store = SdkDataPlaneMigrationStore(home / "one-memory.sqlite3")
|
||||
assert store.authority() is MemoryAuthority.LEGACY
|
||||
# Nothing left to resume: the next run starts from the original data.
|
||||
assert not (home / RESTORE_POINT).exists()
|
||||
assert not restore_pending(home)
|
||||
# The failed state is kept aside for diagnosis; capture stays frozen.
|
||||
assert (failed_state_path(home) / "management" / "one-memory.sqlite3").is_file()
|
||||
assert sdk_migration_capture_fenced(home)
|
||||
events = _events(home)
|
||||
failed_at = next(
|
||||
index
|
||||
for index, line in enumerate(events)
|
||||
if line["event"] == "step.failed" and line["details"]["step"] == step
|
||||
)
|
||||
finished = next(line for line in events if line["event"] == "restore.finished")
|
||||
assert events.index(finished) > failed_at
|
||||
assert finished["details"]["files"] > 0 and finished["details"]["bytes"] > 0
|
||||
assert finished["details"]["reason"] == "migration_failed"
|
||||
assert "duration_ms" in finished["details"]
|
||||
|
||||
monkeypatch.undo()
|
||||
_retry_succeeds(home, monkeypatch)
|
||||
|
||||
|
||||
def test_a_readback_failure_restores_and_a_fixed_build_succeeds(home, monkeypatch):
|
||||
before = _state(home)
|
||||
report: dict[str, object] = {}
|
||||
|
||||
with pytest.raises(MemoryReadbackFailed):
|
||||
_run(home, readable={"m1"}, report=report)
|
||||
|
||||
assert report == {"state": "restored"}
|
||||
assert _state(home) == before
|
||||
# The ledger row the failure wrote went with the rest.
|
||||
with closing(sqlite3.connect(home / "one-memory.sqlite3")) as connection:
|
||||
assert not connection.execute(
|
||||
"SELECT 1 FROM sdk_data_plane_migrations WHERE state='blocked'"
|
||||
).fetchall()
|
||||
_retry_succeeds(home, monkeypatch)
|
||||
|
||||
|
||||
def _retry_succeeds(home: Path, monkeypatch) -> None:
|
||||
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot, vector_collection_bases
|
||||
|
||||
snapshot = builtin_retrieval_snapshot()
|
||||
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: snapshot)
|
||||
restored_bundles = sorted((home / "backups").glob("*.sdk-bundle/restored.json"))
|
||||
assert restored_bundles
|
||||
|
||||
result = _run(home)
|
||||
|
||||
assert result.state == "cutover"
|
||||
store = SdkDataPlaneMigrationStore(home / "one-memory.sqlite3")
|
||||
assert store.authority() is MemoryAuthority.SDK
|
||||
current, _legacy = vector_collection_bases(snapshot)
|
||||
assert _collections(home) == {f"{current}_4": 3}
|
||||
assert not sdk_migration_capture_fenced(home)
|
||||
assert not (home / RESTORE_POINT).exists()
|
||||
assert not failed_state_path(home).exists()
|
||||
# The restored bundle was superseded by this run's verified one.
|
||||
assert not any(path.exists() for path in restored_bundles)
|
||||
assert len(list((home / "backups").glob("*-pre-cutover.sdk-bundle"))) == 1
|
||||
|
||||
|
||||
def test_a_failure_after_the_cutover_commit_is_not_restored(home, monkeypatch):
|
||||
import one_memory.sdk_migration_runner as runner
|
||||
|
||||
def broken_cleanup(**_options):
|
||||
raise DataPlaneMigrationError("sdk_data_plane_cleanup_failed")
|
||||
|
||||
monkeypatch.setattr(runner, "resume_legacy_cleanup", broken_cleanup)
|
||||
report: dict[str, object] = {}
|
||||
|
||||
with pytest.raises(DataPlaneMigrationError):
|
||||
_run(home, report=report)
|
||||
|
||||
assert report == {}
|
||||
store = SdkDataPlaneMigrationStore(home / "one-memory.sqlite3")
|
||||
assert store.authority() is MemoryAuthority.SDK
|
||||
assert legacy_tables_present(home / "one-memory.sqlite3")
|
||||
assert not (home / RESTORE_POINT).exists()
|
||||
assert not any(line["event"].startswith("restore.") for line in _events(home))
|
||||
|
||||
monkeypatch.undo()
|
||||
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
|
||||
|
||||
snapshot = builtin_retrieval_snapshot()
|
||||
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: snapshot)
|
||||
assert _run(home).state == "complete"
|
||||
|
||||
|
||||
def test_a_commit_that_landed_before_its_step_failed_is_not_restored(home, monkeypatch):
|
||||
import one_memory.sdk_migration_runner as runner
|
||||
|
||||
real = runner.commit_sdk_cutover
|
||||
|
||||
def committed_then_failed(**options):
|
||||
real(**options)
|
||||
raise DataPlaneMigrationError("sdk_data_plane_cutover_missing")
|
||||
|
||||
monkeypatch.setattr(runner, "commit_sdk_cutover", committed_then_failed)
|
||||
report: dict[str, object] = {}
|
||||
|
||||
with pytest.raises(DataPlaneMigrationError):
|
||||
_run(home, report=report)
|
||||
|
||||
assert report == {"state": "skipped"}
|
||||
assert (
|
||||
SdkDataPlaneMigrationStore(home / "one-memory.sqlite3").authority() is MemoryAuthority.SDK
|
||||
)
|
||||
skipped = [line for line in _events(home) if line["event"] == "restore.skipped"]
|
||||
assert len(skipped) == 1
|
||||
|
||||
|
||||
def test_a_failed_restore_is_logged_and_the_original_code_is_kept(home, monkeypatch):
|
||||
import one_memory.sdk_migration_runner as runner
|
||||
|
||||
def corrupt_bundle_then_fail(_database_path):
|
||||
bundle = next((home / "backups").glob("*-pre-cutover.sdk-bundle"))
|
||||
(bundle / "management" / "one-memory.sqlite3").write_bytes(b"not the backup")
|
||||
raise DataPlaneMigrationError("sdk_data_plane_dispatch_failed")
|
||||
|
||||
monkeypatch.setattr(runner, "move_pending_dispatches", corrupt_bundle_then_fail)
|
||||
monkeypatch.setattr(cli, "load_settings", lambda: Settings(_env_file=None, data_dir=home))
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"inspect_runtime",
|
||||
lambda _data_dir: RuntimeStatus("stopped", "runtime_receipt_missing"),
|
||||
)
|
||||
output = io.StringIO()
|
||||
|
||||
code = cli.run(["sdk-migrate", "--runtime-build", "22", "--yes", "--json"], output=output)
|
||||
|
||||
document = json.loads(output.getvalue().splitlines()[-1])
|
||||
assert code == 1
|
||||
assert document["code"] == "sdk_data_plane_dispatch_failed"
|
||||
assert document["data_restored"] is False
|
||||
assert document["restore_code"] == "sdk_migration_restore_failed"
|
||||
events = _events(home)
|
||||
restore_failed = next(line for line in events if line["event"] == "restore.failed")
|
||||
assert restore_failed["code"] == "sdk_migration_restore_failed"
|
||||
assert restore_failed["error"]["chain"]
|
||||
migrate_failed = next(line for line in events if line["event"] == "migrate.failed")
|
||||
assert migrate_failed["code"] == "sdk_data_plane_dispatch_failed"
|
||||
assert migrate_failed["details"]["restore"] == "failed"
|
||||
# Nothing was swapped; the restore point stays and the next run refuses
|
||||
# to migrate over the half-migrated home until a restore succeeds.
|
||||
assert (home / RESTORE_POINT).exists()
|
||||
assert not restore_pending(home)
|
||||
monkeypatch.undo()
|
||||
with pytest.raises(MigrationRestoreFailed):
|
||||
_run(home)
|
||||
|
||||
|
||||
def test_a_restore_killed_between_renames_is_finished_before_anything_reads(home, monkeypatch):
|
||||
import one_memory.sdk_migration_restore as restore_module
|
||||
|
||||
before = _state(home)
|
||||
real_apply = restore_module._apply
|
||||
|
||||
def killed_part_way(target, steps, done):
|
||||
real_apply(target, steps[:5], done)
|
||||
raise KeyboardInterrupt
|
||||
|
||||
_fail_vectors(monkeypatch, home)
|
||||
monkeypatch.setattr(restore_module, "_apply", killed_part_way)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
_run(home)
|
||||
|
||||
assert restore_pending(home)
|
||||
# The database was renamed aside: Core must not serve, and the probe
|
||||
# must not call this a fresh home.
|
||||
assert not (home / "one-memory.sqlite3").exists()
|
||||
monkeypatch.setattr(cli, "load_settings", lambda: Settings(_env_file=None, data_dir=home))
|
||||
status = io.StringIO()
|
||||
assert cli.run(["sdk-migration-status", "--json"], output=status) == 0
|
||||
assert json.loads(status.getvalue())["state"] == "required"
|
||||
with pytest.raises(SystemExit) as refused:
|
||||
cli._refuse_unmigrated_home(home)
|
||||
assert refused.value.code == cli.SDK_MIGRATION_REQUIRED_EXIT
|
||||
|
||||
monkeypatch.undo()
|
||||
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
|
||||
|
||||
snapshot = builtin_retrieval_snapshot()
|
||||
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: snapshot)
|
||||
|
||||
assert finish_interrupted_restore(home)
|
||||
|
||||
assert not restore_pending(home)
|
||||
assert _state(home) == before
|
||||
|
||||
|
||||
def test_a_run_killed_after_its_restore_point_is_rolled_back_on_the_next_start(home, monkeypatch):
|
||||
import one_memory.sdk_migration_runner as runner
|
||||
|
||||
before = _state(home)
|
||||
real = runner.reattach_legacy_vectors
|
||||
|
||||
def renamed_then_killed(data_dir, profile):
|
||||
real(data_dir, profile)
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr(runner, "reattach_legacy_vectors", renamed_then_killed)
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
_run(home)
|
||||
assert (home / RESTORE_POINT).exists()
|
||||
monkeypatch.undo()
|
||||
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
|
||||
|
||||
snapshot = builtin_retrieval_snapshot()
|
||||
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: snapshot)
|
||||
|
||||
# The next start first puts the home back, then migrates it from there.
|
||||
def failing_readback(_profile):
|
||||
raise DataPlaneMigrationError("sdk_migration_secure_storage_unavailable")
|
||||
|
||||
with pytest.raises(DataPlaneMigrationError):
|
||||
run_startup_migration(
|
||||
data_dir=home,
|
||||
runtime_build="22",
|
||||
sdk_version="2.0.5",
|
||||
namespace=NAMESPACE,
|
||||
open_client=failing_readback,
|
||||
)
|
||||
reasons = [
|
||||
line["details"]["reason"] for line in _events(home) if line["event"] == "restore.finished"
|
||||
]
|
||||
assert reasons == ["interrupted_attempt", "migration_failed"]
|
||||
assert _state(home) == before
|
||||
|
||||
|
||||
def test_a_preflight_failure_changes_nothing_and_needs_no_restore(home, monkeypatch):
|
||||
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: None)
|
||||
before = _state(home)
|
||||
report: dict[str, object] = {}
|
||||
|
||||
with pytest.raises(DataPlaneMigrationError, match="sdk_migration_model_profile_required"):
|
||||
_run(home, report=report)
|
||||
|
||||
assert report == {}
|
||||
assert _state(home) == before
|
||||
assert not (home / "backups").exists() or not list((home / "backups").iterdir())
|
||||
|
||||
|
||||
def test_a_frozen_capture_fence_holds_until_a_migration_lifts_it(tmp_path):
|
||||
marker = tmp_path / ".sdk-migration-capture-fence.json"
|
||||
marker.write_text(json.dumps({"schema_version": 1, "frozen": True}), encoding="utf-8")
|
||||
|
||||
assert sdk_migration_capture_fenced(tmp_path)
|
||||
assert marker.exists()
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
Companion to `test_memory_server_unified_entry.py` (C2's adapter). That file
|
||||
proves the adapter works in isolation; this one pins the *wiring* in `api.py` /
|
||||
`memory_notes.py`, which is the C0-owned half:
|
||||
`memory_additions.py`, which is the C0-owned half:
|
||||
|
||||
red state - before the wiring, `on_stable_evidence` was the supervisor's
|
||||
`enqueue_evidence` and a committed record had no
|
||||
`memory_write_jobs` row;
|
||||
positive - after it, both the import callback and a manual note submit to
|
||||
positive - after it, both the import callback and a hand-added memory submit to
|
||||
the durable queue under `entry:<evidence_id>`;
|
||||
idempotent - replaying the same evidence returns the original job.
|
||||
"""
|
||||
@@ -173,8 +173,8 @@ def test_import_callback_goes_through_the_durable_intake(tmp_path) -> None:
|
||||
assert callback.__module__ == "one_memory.api"
|
||||
# The shared intake exists and is what the callback closes over.
|
||||
assert app.state.memory_write_intake.queue is not None
|
||||
# Same object for the note path, so both entries share one queue.
|
||||
assert app.state.memory_note_store.intake is app.state.memory_write_intake
|
||||
# Same object for the memory-add path, so both entries share one queue.
|
||||
assert app.state.memory_addition_store.intake is app.state.memory_write_intake
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -225,7 +225,7 @@ def test_live_import_callback_submits_realtime_priority(tmp_path) -> None:
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_manual_note_creates_one_durable_row_and_replays_idempotently(
|
||||
def test_memory_add_creates_one_durable_row_and_replays_idempotently(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def scenario():
|
||||
@@ -237,10 +237,8 @@ def test_manual_note_creates_one_durable_row_and_replays_idempotently(
|
||||
headers={"Authorization": "Bearer wire-test"},
|
||||
)
|
||||
request_id = str(uuid4())
|
||||
created = await client.post(
|
||||
"/api/v1/memory-notes",
|
||||
json={"request_id": request_id, "text": "记住这条", "scope": "all-apps"},
|
||||
)
|
||||
body = {"request_id": request_id, "text": "记住这条", "scope": "all-apps"}
|
||||
created = await client.post("/api/v2/memories", json=body)
|
||||
assert created.status_code == 201
|
||||
evidence_id = created.json()["evidence_id"]
|
||||
key = f"entry:{evidence_id}"
|
||||
@@ -257,10 +255,12 @@ def test_manual_note_creates_one_durable_row_and_replays_idempotently(
|
||||
break
|
||||
assert rows == [(key,)]
|
||||
|
||||
# Idempotent replay: a second /process must not add a second row.
|
||||
assert (
|
||||
await client.post(f"/api/v1/memory-notes/{evidence_id}/process")
|
||||
).status_code == 200
|
||||
# Idempotent replay: the same request submitted again must not add
|
||||
# a second row.
|
||||
replay = await client.post("/api/v2/memories", json=body)
|
||||
assert replay.status_code == 201
|
||||
assert replay.json()["evidence_id"] == evidence_id
|
||||
await asyncio.sleep(0.2)
|
||||
with sqlite3.connect(app.state.evidence_store.database_path) as db:
|
||||
count = db.execute(
|
||||
"SELECT COUNT(*) FROM memory_write_jobs WHERE idempotency_key=?",
|
||||
|
||||
@@ -25,11 +25,14 @@ import {
|
||||
installUserService,
|
||||
RecallBrokerSupervisor,
|
||||
RuntimeSupervisor,
|
||||
readInstalledRuntimeSource,
|
||||
readPublishedCorePort,
|
||||
readRuntimeServiceOwner,
|
||||
readSdkMigrationBlock,
|
||||
removeRuntimeServiceOwner,
|
||||
runtimeInstallLayout,
|
||||
runtimeServiceEnableMarkerPath,
|
||||
type SdkMigrationSource,
|
||||
stopRuntimeProcess,
|
||||
waitForRuntimeProcess,
|
||||
writeRuntimeServiceOwner,
|
||||
@@ -1075,6 +1078,9 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
);
|
||||
installPackagedWebProtocol();
|
||||
|
||||
// The Runtime this launch replaced, when it moved `current`: the migration
|
||||
// log (and so the issue a person files) names the version upgraded from.
|
||||
let upgradeSource: SdkMigrationSource | null = null;
|
||||
const migrateAtStartup = async (core: {
|
||||
command: string;
|
||||
args: string[];
|
||||
@@ -1084,6 +1090,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
dataDir,
|
||||
core,
|
||||
caller: "desktop",
|
||||
sourceRuntime: upgradeSource,
|
||||
runtimeBuild:
|
||||
packagedBuildNumber === undefined
|
||||
? typeof app.getVersion === "function"
|
||||
@@ -1200,6 +1207,12 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
}
|
||||
if (installed !== null) {
|
||||
runtimeChanged = !installed.reused_current;
|
||||
if (runtimeChanged) {
|
||||
upgradeSource = await readInstalledRuntimeSource(
|
||||
installRoot,
|
||||
installed.previous_current_target,
|
||||
);
|
||||
}
|
||||
await installBundledCliIfNeeded({
|
||||
home: app.getPath("home"),
|
||||
installRoot,
|
||||
@@ -1217,6 +1230,12 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
command: stable?.captureCommand ?? layout.coreCommand,
|
||||
args: [],
|
||||
});
|
||||
} else if (desktopBackgroundLaunch) {
|
||||
// A login launch does not migrate, and the service exits on a home a
|
||||
// failed migration froze: say so now instead of waiting for a
|
||||
// Runtime that will not come. Opening MemHub retries the migration.
|
||||
const frozen = await readSdkMigrationBlock(dataDir);
|
||||
if (frozen?.frozen !== undefined) throw new Error(frozen.code);
|
||||
}
|
||||
const alreadyRunning = runtimeChanged
|
||||
? false
|
||||
@@ -1707,6 +1726,23 @@ if (desktopVersion !== null) {
|
||||
showItemInFolder: (target) => shell.showItemInFolder(target),
|
||||
openExternal: (url) => shell.openExternal(url),
|
||||
},
|
||||
environment: {
|
||||
appVersion:
|
||||
typeof app.getVersion === "function" ? app.getVersion() : null,
|
||||
buildNumber: packagedBuildNumber ?? null,
|
||||
osName:
|
||||
process.platform === "darwin"
|
||||
? "macOS"
|
||||
: process.platform === "win32"
|
||||
? "Windows"
|
||||
: "Linux",
|
||||
osVersion:
|
||||
typeof process.getSystemVersion === "function"
|
||||
? process.getSystemVersion()
|
||||
: null,
|
||||
arch: process.arch,
|
||||
home: app.getPath("home"),
|
||||
},
|
||||
})
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
displayPath,
|
||||
MAX_ISSUE_URL_LENGTH,
|
||||
MIGRATION_ISSUE_URL,
|
||||
type MigrationDialogDependencies,
|
||||
migrationFailureCode,
|
||||
migrationIssueBody,
|
||||
migrationIssueUrl,
|
||||
migrationLogPath,
|
||||
readMigrationLogFacts,
|
||||
showMigrationFailureDialog,
|
||||
} from "./migration-failure-dialog";
|
||||
|
||||
@@ -57,8 +61,15 @@ describe("migration failure dialog", () => {
|
||||
expect(deps.showItemInFolder).toHaveBeenCalledWith(
|
||||
migrationLogPath(dataDir),
|
||||
);
|
||||
expect(deps.openExternal).toHaveBeenCalledWith(
|
||||
migrationIssueUrl("sdk_migration_preflight_failed"),
|
||||
const opened = new URL(
|
||||
String((deps.openExternal.mock.calls[0] as unknown[] | undefined)?.[0]),
|
||||
);
|
||||
expect(`${opened.origin}${opened.pathname}`).toBe(MIGRATION_ISSUE_URL);
|
||||
expect(opened.searchParams.get("title")).toBe(
|
||||
"[升级迁移失败 / migration failed] sdk_migration_preflight_failed",
|
||||
);
|
||||
expect(opened.searchParams.get("body")).toContain(
|
||||
"- 错误码:sdk_migration_preflight_failed",
|
||||
);
|
||||
expect(migrationIssueUrl("sdk_migration_preflight_failed")).toMatch(
|
||||
new RegExp(
|
||||
@@ -115,6 +126,170 @@ describe("migration failure dialog", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("fills the issue body from the migration log, never naming the user", async () => {
|
||||
const accountHome = await home();
|
||||
const dataDir = path.join(accountHome, ".one-memory");
|
||||
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
||||
const log = [
|
||||
{
|
||||
event: "migration.invoked",
|
||||
details: { source_version: "unknown", source_build: "unknown" },
|
||||
},
|
||||
{
|
||||
event: "migrate.invoked",
|
||||
details: { source_version: "1.0.4", source_build: "104" },
|
||||
},
|
||||
{
|
||||
event: "step.failed",
|
||||
code: "sdk_data_plane_dispatch_failed",
|
||||
details: {
|
||||
step: "prepare.legacy_work",
|
||||
sub_step: "pending_dispatches",
|
||||
},
|
||||
},
|
||||
{ event: "restore.started", details: {} },
|
||||
{ event: "restore.finished", details: { files: 9 } },
|
||||
{
|
||||
event: "migrate.failed",
|
||||
code: "sdk_data_plane_dispatch_failed",
|
||||
details: {},
|
||||
},
|
||||
]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join("\n");
|
||||
await writeFile(migrationLogPath(dataDir), `${log}\nnot json\n`);
|
||||
await writeFile(
|
||||
path.join(dataDir, "sdk-migration-blocked.json"),
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
code: "sdk_data_plane_dispatch_failed",
|
||||
caller: "desktop",
|
||||
at: "2026-10-27T00:00:00.000Z",
|
||||
frozen: { service_was_enabled: true },
|
||||
data_restored: true,
|
||||
}),
|
||||
);
|
||||
const deps = dependencies(0);
|
||||
|
||||
expect(
|
||||
showMigrationFailureDialog({
|
||||
// Desktop's own migration throws the code the note records.
|
||||
error: new Error("sdk_data_plane_dispatch_failed"),
|
||||
dataDir,
|
||||
locale: "zh-CN",
|
||||
dependencies: deps,
|
||||
environment: {
|
||||
appVersion: "2.0.6",
|
||||
buildNumber: 206,
|
||||
osName: "macOS",
|
||||
osVersion: "15.6",
|
||||
arch: "arm64",
|
||||
home: accountHome,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(deps.showMessageBoxSync.mock.calls[0]?.[0]?.detail).toContain(
|
||||
"记忆数据已恢复到升级前的状态。",
|
||||
);
|
||||
expect(deps.showItemInFolder).toHaveBeenCalledWith(
|
||||
migrationLogPath(dataDir),
|
||||
);
|
||||
const url = String(
|
||||
(deps.openExternal.mock.calls[0] as unknown[] | undefined)?.[0],
|
||||
);
|
||||
expect(url.length).toBeLessThanOrEqual(MAX_ISSUE_URL_LENGTH);
|
||||
const body = new URL(url).searchParams.get("body");
|
||||
expect(body).toBe(
|
||||
[
|
||||
"## 升级迁移失败",
|
||||
"- 从 MemHub 1.0.4(build 104)升级到 2.0.6(build 206),迁移数据失败。",
|
||||
"- 错误码:sdk_data_plane_dispatch_failed",
|
||||
"- 失败步骤:prepare.legacy_work / pending_dispatches",
|
||||
"- 数据已恢复到升级前状态:是",
|
||||
"- 系统:macOS 15.6(arm64)",
|
||||
`- 迁移日志(本地):~${path.sep}${path.join(".one-memory", "logs", "sdk-migration.log")}`,
|
||||
"",
|
||||
"请把上面这个日志文件拖到这里作为附件,再提交。",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(body).not.toContain(accountHome);
|
||||
});
|
||||
|
||||
it("says 未知 for what the log does not know instead of guessing", () => {
|
||||
const body = migrationIssueBody({
|
||||
code: "sdk_migration_preflight_failed",
|
||||
fromVersion: "../../etc",
|
||||
logPath: "~/.one-memory/logs/sdk-migration.log",
|
||||
});
|
||||
expect(body).toContain(
|
||||
"- 从 MemHub 未知(build 未知)升级到 未知(build 未知)",
|
||||
);
|
||||
expect(body).toContain("- 失败步骤:未知");
|
||||
expect(body).toContain("- 数据已恢复到升级前状态:未知");
|
||||
expect(body).toContain("- 系统:未知");
|
||||
});
|
||||
|
||||
it("keeps the issue URL within GitHub's limit and encodes it", () => {
|
||||
const body = `${"迁移".repeat(4000)}\n& = # ?`;
|
||||
const url = migrationIssueUrl("sdk_migration_preflight_failed", body);
|
||||
expect(url.length).toBeLessThanOrEqual(MAX_ISSUE_URL_LENGTH);
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.searchParams.get("title")).toContain(
|
||||
"sdk_migration_preflight_failed",
|
||||
);
|
||||
expect(body.startsWith(parsed.searchParams.get("body") ?? "x")).toBe(true);
|
||||
const small = migrationIssueUrl("sdk_migration_x", "a & b = c #d");
|
||||
expect(new URL(small).searchParams.get("body")).toBe("a & b = c #d");
|
||||
});
|
||||
|
||||
it("shows a custom data dir as it is and the default one under ~", () => {
|
||||
expect(
|
||||
displayPath("/Users/alice/.one-memory/logs/x.log", "/Users/alice"),
|
||||
).toBe(`~${path.sep}${path.join(".one-memory", "logs", "x.log")}`);
|
||||
expect(displayPath("/Volumes/data/memhub/logs/x.log", "/Users/alice")).toBe(
|
||||
"/Volumes/data/memhub/logs/x.log",
|
||||
);
|
||||
expect(displayPath("/Users/alice2/x.log", "/Users/alice")).toBe(
|
||||
"/Users/alice2/x.log",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads only the latest attempt's step and restore from the log", async () => {
|
||||
const dataDir = await home();
|
||||
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
||||
await writeFile(
|
||||
migrationLogPath(dataDir),
|
||||
[
|
||||
{
|
||||
event: "migrate.invoked",
|
||||
details: { source_version: "1.0.4", source_build: 104 },
|
||||
},
|
||||
{ event: "step.failed", details: { step: "vectors.reattach" } },
|
||||
{ event: "restore.finished", details: {} },
|
||||
{
|
||||
event: "migrate.invoked",
|
||||
details: { source_version: "unknown", source_build: "unknown" },
|
||||
},
|
||||
{
|
||||
event: "step.failed",
|
||||
details: { step: "preflight.model_profile_check" },
|
||||
},
|
||||
]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join("\n"),
|
||||
);
|
||||
expect(readMigrationLogFacts(dataDir)).toEqual({
|
||||
fromVersion: "1.0.4",
|
||||
fromBuild: 104,
|
||||
step: "preflight.model_profile_check",
|
||||
subStep: null,
|
||||
code: null,
|
||||
dataRestored: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves other startup failures to the generic error box", async () => {
|
||||
const deps = dependencies(0);
|
||||
expect(
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
appendFileSync,
|
||||
closeSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
@@ -7,30 +15,48 @@ import path from "node:path";
|
||||
* MemHub does not run on a home whose migration did not finish: one data shape
|
||||
* per release, no legacy fallback. The app therefore stops, and this dialog is
|
||||
* the only thing between the failure and a useful report — it names the code
|
||||
* and hands the person the one file support needs (`sdk-migration.log`).
|
||||
* and hands the person the one file support needs (`sdk-migration.log`), with
|
||||
* a GitHub issue already filled in from that log.
|
||||
*/
|
||||
|
||||
export const MIGRATION_ISSUE_URL =
|
||||
"https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new";
|
||||
|
||||
const CODE = /^[a-z][a-z0-9_]{0,95}$/;
|
||||
const STEP = /^[a-z][a-z0-9_.]{0,79}$/;
|
||||
const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/;
|
||||
/** Generic startup codes that hide a migration block recorded by the service. */
|
||||
const MASKING_CODES = new Set([
|
||||
"desktop_start_failed",
|
||||
"runtime_service_not_ready",
|
||||
]);
|
||||
/** Only the tail of the log is read: the last attempt is at its end. */
|
||||
const LOG_TAIL_BYTES = 256 * 1024;
|
||||
/** GitHub rejects very long `issues/new` URLs; stay well below its limit. */
|
||||
export const MAX_ISSUE_URL_LENGTH = 6000;
|
||||
|
||||
export function migrationLogPath(dataDir: string): string {
|
||||
return path.join(dataDir, "logs", "sdk-migration.log");
|
||||
}
|
||||
|
||||
function readBlockCode(dataDir: string): string | null {
|
||||
interface BlockNote {
|
||||
code: string;
|
||||
dataRestored: boolean | null;
|
||||
}
|
||||
|
||||
function readBlock(dataDir: string): BlockNote | null {
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
readFileSync(path.join(dataDir, "sdk-migration-blocked.json"), "utf8"),
|
||||
) as { code?: unknown };
|
||||
) as { code?: unknown; data_restored?: unknown };
|
||||
return typeof value.code === "string" && CODE.test(value.code)
|
||||
? value.code
|
||||
? {
|
||||
code: value.code,
|
||||
dataRestored:
|
||||
typeof value.data_restored === "boolean"
|
||||
? value.data_restored
|
||||
: null,
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -53,8 +79,11 @@ export function migrationFailureCode(
|
||||
return message;
|
||||
}
|
||||
if (dataDir === null) return null;
|
||||
const blocked = readBlockCode(dataDir);
|
||||
const blocked = readBlock(dataDir)?.code ?? null;
|
||||
if (blocked === null) return null;
|
||||
// Desktop's own migration threw the code the note records (for example
|
||||
// `sdk_data_plane_write_job_input_missing`).
|
||||
if (message === blocked) return blocked;
|
||||
return MASKING_CODES.has(message) ||
|
||||
!CODE.test(message) ||
|
||||
message.startsWith("core_")
|
||||
@@ -62,9 +91,201 @@ export function migrationFailureCode(
|
||||
: null;
|
||||
}
|
||||
|
||||
export function migrationIssueUrl(code: string): string {
|
||||
/** What the issue says about the failed upgrade; every field is optional. */
|
||||
export interface MigrationIssueFacts {
|
||||
code: string;
|
||||
fromVersion?: string | null;
|
||||
fromBuild?: number | null;
|
||||
toVersion?: string | null;
|
||||
toBuild?: number | null;
|
||||
step?: string | null;
|
||||
subStep?: string | null;
|
||||
dataRestored?: boolean | null;
|
||||
osName?: string | null;
|
||||
osVersion?: string | null;
|
||||
arch?: string | null;
|
||||
/** The local log path, already shown with the home directory as `~`. */
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
const UNKNOWN = "未知";
|
||||
|
||||
function versionText(value: string | null | undefined): string {
|
||||
return typeof value === "string" && VERSION.test(value) ? value : UNKNOWN;
|
||||
}
|
||||
|
||||
function buildText(value: number | null | undefined): string {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
||||
? String(value)
|
||||
: UNKNOWN;
|
||||
}
|
||||
|
||||
function stepText(
|
||||
step: string | null | undefined,
|
||||
sub: string | null | undefined,
|
||||
) {
|
||||
if (typeof step !== "string" || !STEP.test(step)) return UNKNOWN;
|
||||
return typeof sub === "string" && STEP.test(sub) ? `${step} / ${sub}` : step;
|
||||
}
|
||||
|
||||
/** The issue body: short, content-free, and honest about what is unknown. */
|
||||
export function migrationIssueBody(facts: MigrationIssueFacts): string {
|
||||
const restored =
|
||||
facts.dataRestored === true
|
||||
? "是"
|
||||
: facts.dataRestored === false
|
||||
? "否"
|
||||
: UNKNOWN;
|
||||
const system = [facts.osName, facts.osVersion]
|
||||
.map((part) => versionText(part))
|
||||
.filter((part) => part !== UNKNOWN)
|
||||
.join(" ");
|
||||
const arch = versionText(facts.arch);
|
||||
return [
|
||||
"## 升级迁移失败",
|
||||
`- 从 MemHub ${versionText(facts.fromVersion)}(build ${buildText(facts.fromBuild)})升级到 ${versionText(facts.toVersion)}(build ${buildText(facts.toBuild)}),迁移数据失败。`,
|
||||
`- 错误码:${CODE.test(facts.code) ? facts.code : UNKNOWN}`,
|
||||
`- 失败步骤:${stepText(facts.step, facts.subStep)}`,
|
||||
`- 数据已恢复到升级前状态:${restored}`,
|
||||
`- 系统:${system || UNKNOWN}${arch === UNKNOWN ? "" : `(${arch})`}`,
|
||||
`- 迁移日志(本地):${facts.logPath}`,
|
||||
"",
|
||||
"请把上面这个日志文件拖到这里作为附件,再提交。",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function migrationIssueUrl(code: string, body?: string): string {
|
||||
const title = `[升级迁移失败 / migration failed] ${code}`;
|
||||
return `${MIGRATION_ISSUE_URL}?${new URLSearchParams({ title }).toString()}`;
|
||||
const plain = `${MIGRATION_ISSUE_URL}?${new URLSearchParams({ title }).toString()}`;
|
||||
if (body === undefined) return plain;
|
||||
let text = body;
|
||||
for (;;) {
|
||||
const url = `${MIGRATION_ISSUE_URL}?${new URLSearchParams({ title, body: text }).toString()}`;
|
||||
if (url.length <= MAX_ISSUE_URL_LENGTH) return url;
|
||||
if (text.length === 0) return plain;
|
||||
// Cut whole characters until it fits; the log attachment carries the rest.
|
||||
text = Array.from(text)
|
||||
.slice(0, Math.floor(Array.from(text).length * 0.8))
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
/** `/Users/name/.one-memory/...` as `~/.one-memory/...`: never the user name. */
|
||||
export function displayPath(target: string, home: string | null): string {
|
||||
if (home) {
|
||||
const normalized = path.resolve(home);
|
||||
const resolved = path.resolve(target);
|
||||
if (resolved === normalized) return "~";
|
||||
if (resolved.startsWith(`${normalized}${path.sep}`)) {
|
||||
return `~${path.sep}${path.relative(normalized, resolved)}`;
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export interface MigrationLogFacts {
|
||||
fromVersion: string | null;
|
||||
fromBuild: number | null;
|
||||
step: string | null;
|
||||
subStep: string | null;
|
||||
code: string | null;
|
||||
dataRestored: boolean | null;
|
||||
}
|
||||
|
||||
function readTail(file: string): string {
|
||||
const descriptor = openSync(file, "r");
|
||||
try {
|
||||
const size = fstatSync(descriptor).size;
|
||||
const length = Math.min(size, LOG_TAIL_BYTES);
|
||||
const buffer = Buffer.alloc(length);
|
||||
readSync(descriptor, buffer, 0, length, size - length);
|
||||
return buffer.toString("utf8");
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceBuild(value: unknown): number | null {
|
||||
const number = typeof value === "string" ? Number(value) : value;
|
||||
return typeof number === "number" &&
|
||||
Number.isSafeInteger(number) &&
|
||||
number > 0
|
||||
? number
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the migration log says about the last failed attempt.
|
||||
*
|
||||
* The version upgraded from is the latest one an invocation recorded; the
|
||||
* failing step is the last `step.failed` Core wrote; the restore outcome is
|
||||
* the last `restore.finished`/`restore.failed`. Anything absent stays null.
|
||||
*/
|
||||
export function readMigrationLogFacts(dataDir: string): MigrationLogFacts {
|
||||
const facts: MigrationLogFacts = {
|
||||
fromVersion: null,
|
||||
fromBuild: null,
|
||||
step: null,
|
||||
subStep: null,
|
||||
code: null,
|
||||
dataRestored: null,
|
||||
};
|
||||
let text: string;
|
||||
try {
|
||||
text = readTail(migrationLogPath(dataDir));
|
||||
} catch {
|
||||
return facts;
|
||||
}
|
||||
for (const line of text.split("\n")) {
|
||||
let entry: {
|
||||
event?: unknown;
|
||||
code?: unknown;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
entry = JSON.parse(line) as typeof entry;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const details =
|
||||
entry.details && typeof entry.details === "object" ? entry.details : {};
|
||||
if (
|
||||
entry.event === "migrate.invoked" ||
|
||||
entry.event === "migration.invoked"
|
||||
) {
|
||||
// Each invocation is a new attempt; the version upgraded from stays.
|
||||
facts.step = null;
|
||||
facts.subStep = null;
|
||||
facts.dataRestored = null;
|
||||
const version = details.source_version;
|
||||
const build = sourceBuild(details.source_build);
|
||||
if (
|
||||
typeof version === "string" &&
|
||||
VERSION.test(version) &&
|
||||
version !== "unknown"
|
||||
) {
|
||||
facts.fromVersion = version;
|
||||
}
|
||||
if (build !== null) facts.fromBuild = build;
|
||||
} else if (entry.event === "step.failed") {
|
||||
facts.step = typeof details.step === "string" ? details.step : null;
|
||||
facts.subStep =
|
||||
typeof details.sub_step === "string" ? details.sub_step : null;
|
||||
if (typeof entry.code === "string" && CODE.test(entry.code)) {
|
||||
facts.code = entry.code;
|
||||
}
|
||||
} else if (entry.event === "migrate.failed") {
|
||||
if (typeof entry.code === "string" && CODE.test(entry.code)) {
|
||||
facts.code = entry.code;
|
||||
}
|
||||
} else if (entry.event === "restore.finished") {
|
||||
facts.dataRestored = true;
|
||||
} else if (entry.event === "restore.failed") {
|
||||
facts.dataRestored = false;
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
export interface MigrationDialogText {
|
||||
@@ -79,6 +300,7 @@ export function migrationDialogText(
|
||||
code: string,
|
||||
logPath: string,
|
||||
locale: string,
|
||||
dataRestored = false,
|
||||
): MigrationDialogText {
|
||||
if (locale.toLowerCase().startsWith("en")) {
|
||||
return {
|
||||
@@ -86,7 +308,11 @@ export function migrationDialogText(
|
||||
message: "The data migration for this MemHub upgrade did not finish.",
|
||||
detail:
|
||||
"To protect your data, MemHub will not start. Please attach the migration log to a GitHub issue and send it to us. " +
|
||||
`(Error code: ${code})\n\nLog: ${logPath}`,
|
||||
`(Error code: ${code})` +
|
||||
(dataRestored
|
||||
? "\n\nYour data was restored to its state before the upgrade."
|
||||
: "") +
|
||||
`\n\nLog: ${logPath}`,
|
||||
openLog: "Open log",
|
||||
quit: "Quit",
|
||||
};
|
||||
@@ -96,7 +322,9 @@ export function migrationDialogText(
|
||||
message: "MemHub 升级时数据迁移没有完成。",
|
||||
detail:
|
||||
"为保护你的数据,MemHub 不会启动。请把迁移日志附在 GitHub 反馈里发给我们。" +
|
||||
`(错误码:${code})\n\n日志:${logPath}`,
|
||||
`(错误码:${code})` +
|
||||
(dataRestored ? "\n\n记忆数据已恢复到升级前的状态。" : "") +
|
||||
`\n\n日志:${logPath}`,
|
||||
openLog: "打开日志",
|
||||
quit: "退出",
|
||||
};
|
||||
@@ -117,6 +345,17 @@ export interface MigrationDialogDependencies {
|
||||
openExternal(url: string): Promise<void> | void;
|
||||
}
|
||||
|
||||
/** The running app and system, for the issue body. */
|
||||
export interface MigrationDialogEnvironment {
|
||||
appVersion?: string | null;
|
||||
buildNumber?: number | null;
|
||||
osName?: string | null;
|
||||
osVersion?: string | null;
|
||||
arch?: string | null;
|
||||
/** The account home, shown as `~` in the issue body. */
|
||||
home?: string | null;
|
||||
}
|
||||
|
||||
/** Make sure the file the dialog points at exists and ends with this failure. */
|
||||
function appendFinalLine(dataDir: string, code: string): void {
|
||||
try {
|
||||
@@ -150,12 +389,25 @@ export function showMigrationFailureDialog(options: {
|
||||
dataDir: string | null;
|
||||
locale: string;
|
||||
dependencies: MigrationDialogDependencies;
|
||||
environment?: MigrationDialogEnvironment;
|
||||
}): boolean {
|
||||
const code = migrationFailureCode(options.error, options.dataDir);
|
||||
if (code === null || options.dataDir === null) return false;
|
||||
const block = readBlock(options.dataDir);
|
||||
const logFacts = readMigrationLogFacts(options.dataDir);
|
||||
// The log says whether this attempt restored (是) or failed to (否); a
|
||||
// failure before anything changed has no restore line and stays 未知
|
||||
// rather than a misleading 否.
|
||||
const dataRestored =
|
||||
logFacts.dataRestored ?? (block?.dataRestored === true ? true : null);
|
||||
appendFinalLine(options.dataDir, code);
|
||||
const logPath = migrationLogPath(options.dataDir);
|
||||
const text = migrationDialogText(code, logPath, options.locale);
|
||||
const text = migrationDialogText(
|
||||
code,
|
||||
logPath,
|
||||
options.locale,
|
||||
dataRestored === true,
|
||||
);
|
||||
const choice = options.dependencies.showMessageBoxSync({
|
||||
type: "error",
|
||||
title: text.title,
|
||||
@@ -168,8 +420,23 @@ export function showMigrationFailureDialog(options: {
|
||||
});
|
||||
if (choice === 0) {
|
||||
options.dependencies.showItemInFolder(logPath);
|
||||
const environment = options.environment ?? {};
|
||||
const body = migrationIssueBody({
|
||||
code,
|
||||
fromVersion: logFacts.fromVersion,
|
||||
fromBuild: logFacts.fromBuild,
|
||||
toVersion: environment.appVersion ?? null,
|
||||
toBuild: environment.buildNumber ?? null,
|
||||
step: logFacts.step,
|
||||
subStep: logFacts.subStep,
|
||||
dataRestored,
|
||||
osName: environment.osName ?? null,
|
||||
osVersion: environment.osVersion ?? null,
|
||||
arch: environment.arch ?? null,
|
||||
logPath: displayPath(logPath, environment.home ?? null),
|
||||
});
|
||||
void Promise.resolve(
|
||||
options.dependencies.openExternal(migrationIssueUrl(code)),
|
||||
options.dependencies.openExternal(migrationIssueUrl(code, body)),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -536,7 +536,7 @@ export function HistoryImport({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="memory-note-heading">
|
||||
<header className="memory-add-heading">
|
||||
<h2 id="history-import-title">导入历史包</h2>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+92
-81
@@ -1,17 +1,17 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { MemoryNoteReceipt } from "@one-memory/contracts";
|
||||
import type { MemoryAddReceipt } from "@one-memory/contracts";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
import { MemoryNoteComposer } from "./MemoryNoteComposer";
|
||||
import { MemoryAddComposer, RECENT_ADDITIONS_KEY } from "./MemoryAddComposer";
|
||||
|
||||
it("opens a Spanish composer without saving, processing or enabling anything", async () => {
|
||||
delete window.oneMemory;
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<MemoryNoteComposer
|
||||
<MemoryAddComposer
|
||||
onSaved={vi.fn()}
|
||||
onOpenMemory={vi.fn()}
|
||||
onConfigureModel={vi.fn()}
|
||||
@@ -32,24 +32,22 @@ it("opens a Spanish composer without saving, processing or enabling anything", a
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Cerrar nueva memoria" }),
|
||||
);
|
||||
expect(api.saveMemoryNote).not.toHaveBeenCalled();
|
||||
expect(api.processMemoryNote).not.toHaveBeenCalled();
|
||||
expect(api.addMemory).not.toHaveBeenCalled();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getMemoryNotes: vi.fn(),
|
||||
getMemoryNoteOperation: vi.fn(),
|
||||
getMemoryAddStatus: vi.fn(),
|
||||
getModelProfiles: vi.fn(),
|
||||
processMemoryNote: vi.fn(),
|
||||
saveMemoryNote: vi.fn(),
|
||||
addMemory: vi.fn(),
|
||||
apiErrorCode: vi.fn(),
|
||||
getSources: vi.fn(),
|
||||
}));
|
||||
vi.mock("./api", () => api);
|
||||
|
||||
const note: MemoryNoteReceipt = {
|
||||
evidence_id: "note-1",
|
||||
const REQUEST_ID = "11111111-2222-4333-8444-555555555555";
|
||||
const addition: MemoryAddReceipt = {
|
||||
evidence_id: "evidence-1",
|
||||
text: "项目周会不排在周五。",
|
||||
scope: "all-apps",
|
||||
created_at: "2026-09-12T00:00:00Z",
|
||||
@@ -58,17 +56,29 @@ const note: MemoryNoteReceipt = {
|
||||
memory_ids: [],
|
||||
error: null,
|
||||
};
|
||||
/** This device remembers one earlier addition whose status reads as `receipt`. */
|
||||
function remembered(...receipts: MemoryAddReceipt[]) {
|
||||
localStorage.setItem(RECENT_ADDITIONS_KEY, JSON.stringify([REQUEST_ID]));
|
||||
for (const receipt of receipts)
|
||||
api.getMemoryAddStatus.mockResolvedValueOnce(receipt);
|
||||
if (receipts.length)
|
||||
api.getMemoryAddStatus.mockResolvedValue(receipts.at(-1));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
api.getMemoryNotes.mockResolvedValue([]);
|
||||
api.getMemoryNoteOperation.mockRejectedValue(new Error("not found"));
|
||||
localStorage.removeItem(RECENT_ADDITIONS_KEY);
|
||||
api.getMemoryAddStatus.mockRejectedValue(new Error("not found"));
|
||||
api.getModelProfiles.mockResolvedValue([]);
|
||||
api.saveMemoryNote.mockResolvedValue(note);
|
||||
api.addMemory.mockResolvedValue(addition);
|
||||
api.apiErrorCode.mockImplementation(
|
||||
(reason) => reason?.response?.data?.detail ?? null,
|
||||
);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.removeItem(RECENT_ADDITIONS_KEY);
|
||||
});
|
||||
|
||||
it("OBS-18: reads the configured model before saying it is not configured", async () => {
|
||||
let release: (value: unknown) => void = () => {};
|
||||
@@ -95,13 +105,11 @@ it("OBS-18: reads the configured model before saying it is not configured", asyn
|
||||
});
|
||||
|
||||
it("explains source verification failure without retrying or claiming success", async () => {
|
||||
api.getMemoryNotes.mockResolvedValue([
|
||||
{
|
||||
...note,
|
||||
state: "failed",
|
||||
error: "hy_memory_write_verification_historical_support_only",
|
||||
},
|
||||
]);
|
||||
remembered({
|
||||
...addition,
|
||||
state: "failed",
|
||||
error: "hy_memory_write_verification_historical_support_only",
|
||||
});
|
||||
page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
expect(
|
||||
@@ -110,7 +118,8 @@ it("explains source verification failure without retrying or claiming success",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("整理已完成")).toBeNull();
|
||||
expect(api.processMemoryNote).not.toHaveBeenCalled();
|
||||
expect(api.getMemoryAddStatus).toHaveBeenCalledWith(REQUEST_ID);
|
||||
expect(screen.queryByRole("button", { name: "重新整理" })).toBeNull();
|
||||
});
|
||||
|
||||
function page() {
|
||||
@@ -119,7 +128,7 @@ function page() {
|
||||
onConfigureModel = vi.fn(),
|
||||
onPendingChange = vi.fn();
|
||||
const view = render(
|
||||
<MemoryNoteComposer
|
||||
<MemoryAddComposer
|
||||
{...{ onSaved, onOpenMemory, onConfigureModel, onPendingChange }}
|
||||
/>,
|
||||
);
|
||||
@@ -132,28 +141,28 @@ function page() {
|
||||
};
|
||||
}
|
||||
|
||||
it("reports a saved note that still needs processing when the panel is closed", async () => {
|
||||
api.getMemoryNotes.mockResolvedValue([note]);
|
||||
it("reports a saved addition that still needs processing when the panel is closed", async () => {
|
||||
remembered(addition);
|
||||
const callbacks = page();
|
||||
await waitFor(() =>
|
||||
expect(callbacks.onPendingChange).toHaveBeenCalledWith({
|
||||
evidenceId: note.evidence_id,
|
||||
text: note.text,
|
||||
evidenceId: addition.evidence_id,
|
||||
text: addition.text,
|
||||
state: "waiting_for_model",
|
||||
}),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
await waitFor(() =>
|
||||
expect(callbacks.onPendingChange).toHaveBeenCalledWith({
|
||||
evidenceId: note.evidence_id,
|
||||
text: note.text,
|
||||
evidenceId: addition.evidence_id,
|
||||
text: addition.text,
|
||||
state: "waiting_for_model",
|
||||
}),
|
||||
);
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith({
|
||||
evidenceId: note.evidence_id,
|
||||
text: note.text,
|
||||
evidenceId: addition.evidence_id,
|
||||
text: addition.text,
|
||||
state: "waiting_for_model",
|
||||
});
|
||||
});
|
||||
@@ -163,7 +172,7 @@ it("saves from an empty library with the linked-app scope by default", async ()
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
const input = screen.getByRole("textbox", { name: "记下这件事" });
|
||||
expect(document.activeElement).toBe(input);
|
||||
await userEvent.type(input, note.text);
|
||||
await userEvent.type(input, addition.text);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "保存" }).hasAttribute("disabled"),
|
||||
).toBe(false);
|
||||
@@ -172,9 +181,9 @@ it("saves from an empty library with the linked-app scope by default", async ()
|
||||
).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
await waitFor(() =>
|
||||
expect(api.saveMemoryNote).toHaveBeenCalledWith(
|
||||
expect(api.addMemory).toHaveBeenCalledWith(
|
||||
{
|
||||
text: note.text,
|
||||
text: addition.text,
|
||||
scope: "all-apps",
|
||||
request_id: expect.any(String),
|
||||
},
|
||||
@@ -190,9 +199,9 @@ it("saves from an empty library with the linked-app scope by default", async ()
|
||||
});
|
||||
|
||||
it("preserves draft and request identity after an uncertain save", async () => {
|
||||
api.saveMemoryNote
|
||||
api.addMemory
|
||||
.mockRejectedValueOnce(new Error("timeout"))
|
||||
.mockResolvedValueOnce(note);
|
||||
.mockResolvedValueOnce(addition);
|
||||
const emitted: Array<{ phase: string; status?: string; reason?: string }> =
|
||||
[];
|
||||
window.oneMemory = {
|
||||
@@ -207,11 +216,11 @@ it("preserves draft and request identity after an uncertain save", async () => {
|
||||
} as unknown as NonNullable<typeof window.oneMemory>;
|
||||
page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
await userEvent.type(screen.getByRole("textbox"), note.text);
|
||||
await userEvent.type(screen.getByRole("textbox"), addition.text);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(
|
||||
note.text,
|
||||
addition.text,
|
||||
);
|
||||
// A plain timeout error is not proof that Core refused the write: the copy
|
||||
// says the result is unconfirmed, so the log must not say "failed".
|
||||
@@ -220,36 +229,37 @@ it("preserves draft and request identity after an uncertain save", async () => {
|
||||
expect(result?.reason).toBe("save_result_unconfirmed");
|
||||
expect(result?.status).not.toBe("failed");
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
expect(api.saveMemoryNote.mock.calls[0][0].request_id).toBe(
|
||||
api.saveMemoryNote.mock.calls[1][0].request_id,
|
||||
expect(api.addMemory.mock.calls[0][0].request_id).toBe(
|
||||
api.addMemory.mock.calls[1][0].request_id,
|
||||
);
|
||||
delete (window as unknown as { oneMemory?: unknown }).oneMemory;
|
||||
});
|
||||
|
||||
it("closes as saved when the POST response is lost but the operation exists", async () => {
|
||||
api.saveMemoryNote.mockRejectedValueOnce(new Error("timeout"));
|
||||
api.getMemoryNoteOperation.mockResolvedValueOnce({
|
||||
...note,
|
||||
it("closes as saved when the POST response is lost but the addition exists", async () => {
|
||||
api.addMemory.mockRejectedValueOnce(new Error("timeout"));
|
||||
api.getMemoryAddStatus.mockResolvedValueOnce({
|
||||
...addition,
|
||||
state: "pending",
|
||||
});
|
||||
const callbacks = page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
await userEvent.type(screen.getByRole("textbox"), note.text);
|
||||
await userEvent.type(screen.getByRole("textbox"), addition.text);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
await waitFor(() =>
|
||||
expect(api.getMemoryNoteOperation).toHaveBeenCalledOnce(),
|
||||
);
|
||||
const requestId = api.saveMemoryNote.mock.calls[0][0].request_id;
|
||||
expect(api.getMemoryNoteOperation).toHaveBeenCalledWith(requestId);
|
||||
await waitFor(() => expect(api.getMemoryAddStatus).toHaveBeenCalled());
|
||||
const requestId = api.addMemory.mock.calls[0][0].request_id;
|
||||
// The recovery read asks for exactly this request's addition.
|
||||
expect(api.getMemoryAddStatus.mock.calls[0]).toEqual([requestId]);
|
||||
// The recovered addition is remembered for the recent list.
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(RECENT_ADDITIONS_KEY) ?? "[]"),
|
||||
).toEqual([requestId]);
|
||||
expect(screen.getByText("已保存,正在后台沉淀记忆")).toBeTruthy();
|
||||
expect(screen.queryByRole("dialog", { name: "添加记忆" })).toBeNull();
|
||||
expect(callbacks.onSaved).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("opens a persisted result in the existing memory editor", async () => {
|
||||
api.getMemoryNotes.mockResolvedValue([
|
||||
{ ...note, state: "ready", memory_ids: ["memory-7"] },
|
||||
]);
|
||||
remembered({ ...addition, state: "ready", memory_ids: ["memory-7"] });
|
||||
const callbacks = page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
await userEvent.click(
|
||||
@@ -269,12 +279,11 @@ it("closes with Escape and restores the trigger focus", async () => {
|
||||
});
|
||||
|
||||
it("refreshes the workbench once asynchronous extraction becomes ready", async () => {
|
||||
api.getMemoryNotes
|
||||
.mockResolvedValueOnce([{ ...note, state: "running" }])
|
||||
.mockResolvedValueOnce([{ ...note, state: "running" }]);
|
||||
api.getMemoryNotes.mockResolvedValue([
|
||||
{ ...note, state: "ready", memory_ids: ["new-memory"] },
|
||||
]);
|
||||
remembered(
|
||||
{ ...addition, state: "running" },
|
||||
{ ...addition, state: "running" },
|
||||
{ ...addition, state: "ready", memory_ids: ["new-memory"] },
|
||||
);
|
||||
const callbacks = page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
expect(callbacks.onSaved).not.toHaveBeenCalled();
|
||||
@@ -284,35 +293,37 @@ it("refreshes the workbench once asynchronous extraction becomes ready", async (
|
||||
expect(await screen.findByRole("button", { name: "查看记忆" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not ask for separate consent when an old note is waiting for permission", async () => {
|
||||
api.getMemoryNotes.mockResolvedValue([
|
||||
{ ...note, state: "waiting_for_permission" },
|
||||
]);
|
||||
api.processMemoryNote.mockResolvedValue({ ...note, state: "pending" });
|
||||
it("shows an old addition waiting for permission without consent or a manual retry", async () => {
|
||||
remembered({ ...addition, state: "waiting_for_permission" });
|
||||
page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
|
||||
expect(await screen.findByText("正在等待整理")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "模型授权" })).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: "重新整理" }));
|
||||
expect(api.processMemoryNote).toHaveBeenCalledExactlyOnceWith(
|
||||
note.evidence_id,
|
||||
expect.any(String),
|
||||
);
|
||||
// Processing is not a separate write: activating a model readmits parked
|
||||
// work, so there is no "process again" control on an addition.
|
||||
expect(screen.queryByRole("button", { name: "重新整理" })).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves ordinary processing-failure retry without opening model consent", async () => {
|
||||
api.getMemoryNotes.mockResolvedValue([
|
||||
{ ...note, state: "failed", error: "hy_memory_model_payment_required" },
|
||||
]);
|
||||
api.processMemoryNote.mockResolvedValue({ ...note, state: "pending" });
|
||||
it("forgets a remembered addition Core no longer knows", async () => {
|
||||
localStorage.setItem(RECENT_ADDITIONS_KEY, JSON.stringify([REQUEST_ID]));
|
||||
api.getMemoryAddStatus.mockRejectedValue({ response: { status: 404 } });
|
||||
page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "重新整理" }),
|
||||
);
|
||||
expect(api.processMemoryNote).toHaveBeenCalledExactlyOnceWith(
|
||||
note.evidence_id,
|
||||
expect.any(String),
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem(RECENT_ADDITIONS_KEY)).toBe("[]"),
|
||||
);
|
||||
expect(screen.queryByRole("region", { name: "最近添加" })).toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps remembered additions when a status read fails for another reason", async () => {
|
||||
localStorage.setItem(RECENT_ADDITIONS_KEY, JSON.stringify([REQUEST_ID]));
|
||||
api.getMemoryAddStatus.mockRejectedValue({ response: { status: 503 } });
|
||||
page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(RECENT_ADDITIONS_KEY) ?? "[]"),
|
||||
).toEqual([REQUEST_ID]);
|
||||
});
|
||||
+177
-141
@@ -1,20 +1,13 @@
|
||||
import type { MemoryNoteReceipt, ModelProfile } from "@one-memory/contracts";
|
||||
import { FilePlus2, RefreshCw, X } from "lucide-react";
|
||||
import type { MemoryAddReceipt, ModelProfile } from "@one-memory/contracts";
|
||||
import { FilePlus2, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
getMemoryNoteOperation,
|
||||
getMemoryNotes,
|
||||
getModelProfiles,
|
||||
processMemoryNote,
|
||||
saveMemoryNote,
|
||||
} from "./api";
|
||||
import { addMemory, getMemoryAddStatus, getModelProfiles } from "./api";
|
||||
import { useMemoryLanguage } from "./memory-language";
|
||||
import { memoryWriteErrorText } from "./model-error-text";
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
import { isDefiniteRefusal } from "./write-outcome";
|
||||
|
||||
const states: Record<MemoryNoteReceipt["state"], string> = {
|
||||
const states: Record<MemoryAddReceipt["state"], string> = {
|
||||
waiting_for_model: "原话已保存,等待可用模型",
|
||||
waiting_for_permission: "正在等待整理",
|
||||
pending: "正在等待整理",
|
||||
@@ -24,13 +17,87 @@ const states: Record<MemoryNoteReceipt["state"], string> = {
|
||||
no_memories: "原话已保存,未提取出可用记忆",
|
||||
};
|
||||
|
||||
export interface PendingMemoryNote {
|
||||
export interface PendingMemoryAdd {
|
||||
evidenceId: string;
|
||||
text: string;
|
||||
state: Exclude<MemoryNoteReceipt["state"], "ready" | "no_memories">;
|
||||
state: Exclude<MemoryAddReceipt["state"], "ready" | "no_memories">;
|
||||
}
|
||||
|
||||
export function MemoryNoteComposer({
|
||||
/** One addition this device made, with the request id its status is read by. */
|
||||
interface Addition {
|
||||
requestId: string;
|
||||
receipt: MemoryAddReceipt;
|
||||
}
|
||||
|
||||
/** Core has no list of additions: this device remembers the request ids it
|
||||
* submitted and reads each one's status from
|
||||
* `GET /api/v2/memories/additions/{request_id}`. */
|
||||
export const RECENT_ADDITIONS_KEY = "memhub.memory-additions.recent";
|
||||
const RECENT_ADDITIONS_MAX = 20;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
function readRecentAdditions(): string[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(
|
||||
window.localStorage.getItem(RECENT_ADDITIONS_KEY) ?? "[]",
|
||||
);
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter((id): id is string => typeof id === "string" && UUID.test(id))
|
||||
.slice(0, RECENT_ADDITIONS_MAX);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecentAdditions(ids: string[]) {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
RECENT_ADDITIONS_KEY,
|
||||
JSON.stringify(ids.slice(0, RECENT_ADDITIONS_MAX)),
|
||||
);
|
||||
} catch {
|
||||
// Storage is a convenience for the recent list; the memory is saved.
|
||||
}
|
||||
}
|
||||
|
||||
function trackRecentAddition(requestId: string) {
|
||||
writeRecentAdditions([
|
||||
requestId,
|
||||
...readRecentAdditions().filter((id) => id !== requestId),
|
||||
]);
|
||||
}
|
||||
|
||||
function isNotFound(reason: unknown): boolean {
|
||||
return (
|
||||
!!reason &&
|
||||
typeof reason === "object" &&
|
||||
(reason as { response?: { status?: unknown } }).response?.status === 404
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the status of every remembered addition. Unknown ids (404) are
|
||||
* forgotten; any other failure throws so the caller keeps its last state. */
|
||||
async function loadRecentAdditions(): Promise<Addition[]> {
|
||||
const ids = readRecentAdditions();
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => getMemoryAddStatus(id)),
|
||||
);
|
||||
const additions: Addition[] = [];
|
||||
const gone = new Set<string>();
|
||||
results.forEach((result, index) => {
|
||||
const requestId = ids[index] as string;
|
||||
if (result.status === "fulfilled")
|
||||
additions.push({ requestId, receipt: result.value });
|
||||
else if (isNotFound(result.reason)) gone.add(requestId);
|
||||
else throw result.reason;
|
||||
});
|
||||
if (gone.size)
|
||||
writeRecentAdditions(readRecentAdditions().filter((id) => !gone.has(id)));
|
||||
return additions;
|
||||
}
|
||||
|
||||
export function MemoryAddComposer({
|
||||
onSaved,
|
||||
onOpenMemory,
|
||||
onConfigureModel,
|
||||
@@ -40,13 +107,13 @@ export function MemoryNoteComposer({
|
||||
onSaved(): void;
|
||||
onOpenMemory(id: string): void;
|
||||
onConfigureModel(): void;
|
||||
onPendingChange?(note: PendingMemoryNote | null): void;
|
||||
onPendingChange?(addition: PendingMemoryAdd | null): void;
|
||||
suspended?: boolean;
|
||||
}) {
|
||||
const { text: copy } = useMemoryLanguage();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [text, setText] = useState("");
|
||||
const [notes, setNotes] = useState<MemoryNoteReceipt[]>([]);
|
||||
const [additions, setAdditions] = useState<Addition[]>([]);
|
||||
const [model, setModel] = useState<ModelProfile | null>(null);
|
||||
// Before the read settles, `model` is null for the same reason it is null
|
||||
// when nothing is configured. Showing the configured-model copy anyway made
|
||||
@@ -58,7 +125,7 @@ export function MemoryNoteComposer({
|
||||
const [savedToast, setSavedToast] = useState("");
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const isOpen = useRef(false);
|
||||
const notesRequest = useRef(0);
|
||||
const additionsRequest = useRef(0);
|
||||
const intent = useRef<{ text: string; requestId: string } | null>(null);
|
||||
const completed = useRef(new Set<string>());
|
||||
const lastSaved = useRef<string | null>(null);
|
||||
@@ -67,49 +134,52 @@ export function MemoryNoteComposer({
|
||||
const onPendingChangeRef = useRef(onPendingChange);
|
||||
onPendingChangeRef.current = onPendingChange;
|
||||
|
||||
const reportPending = useCallback((value: MemoryNoteReceipt[]) => {
|
||||
const note = value.find(
|
||||
(
|
||||
item,
|
||||
): item is MemoryNoteReceipt & {
|
||||
state: PendingMemoryNote["state"];
|
||||
} => item.state !== "ready" && item.state !== "no_memories",
|
||||
);
|
||||
const reportPending = useCallback((value: Addition[]) => {
|
||||
const pending = value
|
||||
.map((item) => item.receipt)
|
||||
.find(
|
||||
(
|
||||
item,
|
||||
): item is MemoryAddReceipt & {
|
||||
state: PendingMemoryAdd["state"];
|
||||
} => item.state !== "ready" && item.state !== "no_memories",
|
||||
);
|
||||
onPendingChangeRef.current?.(
|
||||
note
|
||||
pending
|
||||
? {
|
||||
evidenceId: note.evidence_id,
|
||||
text: note.text,
|
||||
state: note.state,
|
||||
evidenceId: pending.evidence_id,
|
||||
text: pending.text,
|
||||
state: pending.state,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const refreshNotes = useCallback(
|
||||
const refreshAdditions = useCallback(
|
||||
async (failure: string, notifyResults = false) => {
|
||||
const request = ++notesRequest.current;
|
||||
const request = ++additionsRequest.current;
|
||||
try {
|
||||
const value = await getMemoryNotes();
|
||||
if (!isOpen.current || request !== notesRequest.current) return;
|
||||
setNotes(value);
|
||||
const value = await loadRecentAdditions();
|
||||
if (!isOpen.current || request !== additionsRequest.current) return;
|
||||
setAdditions(value);
|
||||
reportPending(value);
|
||||
if (!notifyResults) return;
|
||||
const latest = value.find(
|
||||
(note) => note.evidence_id === lastSaved.current,
|
||||
(item) => item.receipt.evidence_id === lastSaved.current,
|
||||
);
|
||||
if (latest) setNotice(states[latest.state]);
|
||||
if (latest) setNotice(states[latest.receipt.state]);
|
||||
const newResults = value.filter(
|
||||
(note) =>
|
||||
note.state === "ready" && !completed.current.has(note.evidence_id),
|
||||
({ receipt }) =>
|
||||
receipt.state === "ready" &&
|
||||
!completed.current.has(receipt.evidence_id),
|
||||
);
|
||||
if (newResults.length) {
|
||||
for (const note of newResults)
|
||||
completed.current.add(note.evidence_id);
|
||||
for (const { receipt } of newResults)
|
||||
completed.current.add(receipt.evidence_id);
|
||||
onSavedRef.current();
|
||||
}
|
||||
} catch {
|
||||
if (isOpen.current && request === notesRequest.current)
|
||||
if (isOpen.current && request === additionsRequest.current)
|
||||
setError(failure);
|
||||
}
|
||||
},
|
||||
@@ -122,7 +192,7 @@ export function MemoryNoteComposer({
|
||||
let cancelled = false;
|
||||
const before = document.activeElement;
|
||||
panel.current?.querySelector("textarea")?.focus();
|
||||
void refreshNotes("最近添加的内容读取失败。");
|
||||
void refreshAdditions("最近添加的内容读取失败。");
|
||||
setModelRead(false);
|
||||
getModelProfiles()
|
||||
.then((value) => {
|
||||
@@ -138,14 +208,14 @@ export function MemoryNoteComposer({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
isOpen.current = false;
|
||||
notesRequest.current += 1;
|
||||
additionsRequest.current += 1;
|
||||
if (before instanceof HTMLElement && before.isConnected) before.focus();
|
||||
};
|
||||
}, [open, refreshNotes, suspended]);
|
||||
}, [open, refreshAdditions, suspended]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open || suspended || !onPendingChange) return;
|
||||
void getMemoryNotes()
|
||||
void loadRecentAdditions()
|
||||
.then((value) => reportPending(value))
|
||||
.catch(() => {
|
||||
// The page already keeps the last known state when a background read
|
||||
@@ -153,20 +223,20 @@ export function MemoryNoteComposer({
|
||||
});
|
||||
}, [open, onPendingChange, reportPending, suspended]);
|
||||
|
||||
const working = notes.some(
|
||||
(note) => note.state === "pending" || note.state === "running",
|
||||
const working = additions.some(
|
||||
({ receipt }) => receipt.state === "pending" || receipt.state === "running",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!open || suspended || !working) return;
|
||||
const stop = subscribePoll({
|
||||
// The note list and the processing status are two surfaces; sharing one
|
||||
// key would make one tick read the wrong endpoint for the other caller.
|
||||
key: "note-processing",
|
||||
// A key of its own: sharing one with another surface would make one
|
||||
// tick read the wrong endpoint for the other caller.
|
||||
key: "memory-add-processing",
|
||||
intervalMs: 1_500,
|
||||
run: () => refreshNotes("整理状态暂时不可用,原话已保存。", true),
|
||||
run: () => refreshAdditions("整理状态暂时不可用,原话已保存。", true),
|
||||
});
|
||||
return stop;
|
||||
}, [open, working, refreshNotes, suspended]);
|
||||
}, [open, working, refreshAdditions, suspended]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!savedToast) return;
|
||||
@@ -177,15 +247,16 @@ export function MemoryNoteComposer({
|
||||
function close() {
|
||||
if (busy) return;
|
||||
isOpen.current = false;
|
||||
notesRequest.current += 1;
|
||||
additionsRequest.current += 1;
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function remember(receipt: MemoryNoteReceipt) {
|
||||
setNotes((current) => {
|
||||
function remember(addition: Addition) {
|
||||
trackRecentAddition(addition.requestId);
|
||||
setAdditions((current) => {
|
||||
const next = [
|
||||
receipt,
|
||||
...current.filter((note) => note.evidence_id !== receipt.evidence_id),
|
||||
addition,
|
||||
...current.filter((item) => item.requestId !== addition.requestId),
|
||||
];
|
||||
reportPending(next);
|
||||
return next;
|
||||
@@ -196,28 +267,29 @@ export function MemoryNoteComposer({
|
||||
if (busy || !text.trim()) return;
|
||||
if (intent.current?.text !== text)
|
||||
intent.current = { text, requestId: crypto.randomUUID() };
|
||||
const requestId = intent.current.requestId;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
const trace = traceUiAction("memory", "submit");
|
||||
const completeSave = (receipt: MemoryNoteReceipt) => {
|
||||
remember(receipt);
|
||||
const completeSave = (receipt: MemoryAddReceipt) => {
|
||||
remember({ requestId, receipt });
|
||||
lastSaved.current = receipt.evidence_id;
|
||||
setText("");
|
||||
intent.current = null;
|
||||
setNotice("");
|
||||
setSavedToast("已保存,正在后台沉淀记忆");
|
||||
isOpen.current = false;
|
||||
notesRequest.current += 1;
|
||||
additionsRequest.current += 1;
|
||||
setOpen(false);
|
||||
onSaved();
|
||||
};
|
||||
try {
|
||||
const receipt = await saveMemoryNote(
|
||||
const receipt = await addMemory(
|
||||
{
|
||||
text,
|
||||
scope: "all-apps",
|
||||
request_id: intent.current.requestId,
|
||||
request_id: requestId,
|
||||
},
|
||||
trace.traceId,
|
||||
);
|
||||
@@ -225,14 +297,14 @@ export function MemoryNoteComposer({
|
||||
trace.done("succeeded");
|
||||
} catch {
|
||||
try {
|
||||
completeSave(await getMemoryNoteOperation(intent.current.requestId));
|
||||
// The save landed but its response was lost; the note is still saved,
|
||||
// so the interaction succeeded even though this attempt did not.
|
||||
completeSave(await getMemoryAddStatus(requestId));
|
||||
// The save landed but its response was lost; the memory is still
|
||||
// saved, so the interaction succeeded even though this attempt did not.
|
||||
trace.done("succeeded", { reason: "recovered_from_operation" });
|
||||
} catch {
|
||||
setError("未能确认保存结果,输入已保留。重试不会重复添加。");
|
||||
// The copy already says the result is *unconfirmed*: neither the POST
|
||||
// nor the receipt re-read came back. Only a definite refusal is a
|
||||
// nor the status re-read came back. Only a definite refusal is a
|
||||
// failure, and here there is none, so the log must not claim one.
|
||||
trace.done("unknown", { reason: "save_result_unconfirmed" });
|
||||
}
|
||||
@@ -241,45 +313,18 @@ export function MemoryNoteComposer({
|
||||
}
|
||||
}
|
||||
|
||||
async function retry(note: MemoryNoteReceipt) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
// Starting processing is a real write, so it needs its own chain.
|
||||
const trace = traceUiAction("memory", "retry");
|
||||
try {
|
||||
remember(await processMemoryNote(note.evidence_id, trace.traceId));
|
||||
trace.done("succeeded");
|
||||
} catch (reason) {
|
||||
const refused = isDefiniteRefusal(reason);
|
||||
trace.done(refused ? "failed" : "unknown", {
|
||||
reason: refused ? "note_process_failed" : "note_process_result_unknown",
|
||||
});
|
||||
// Only a definite refusal may be phrased as "did not start"; a timeout or
|
||||
// 5xx means the outcome is unknown, and saying it failed would be a claim
|
||||
// the UI cannot support.
|
||||
setError(
|
||||
refused
|
||||
? "整理未能开始,原话仍已保存。"
|
||||
: "整理结果尚未确认,原话仍已保存。请刷新后核对。",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary memory-note-trigger"
|
||||
data-tour-id="memory-note-trigger"
|
||||
className="secondary memory-add-trigger"
|
||||
data-tour-id="memory-add-trigger"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<FilePlus2 size={16} aria-hidden="true" /> {copy("添加记忆")}
|
||||
</button>
|
||||
{savedToast && (
|
||||
<p className="memory-note-toast" role="status">
|
||||
<p className="memory-add-toast" role="status">
|
||||
{copy(savedToast)}
|
||||
</p>
|
||||
)}
|
||||
@@ -287,9 +332,9 @@ export function MemoryNoteComposer({
|
||||
<div className="consent-backdrop" hidden={suspended}>
|
||||
<section
|
||||
ref={panel}
|
||||
className="consent-sheet memory-note-sheet"
|
||||
className="consent-sheet memory-add-sheet"
|
||||
{...(suspended ? {} : { role: "dialog", "aria-modal": true })}
|
||||
aria-labelledby="memory-note-title"
|
||||
aria-labelledby="memory-add-title"
|
||||
onKeyDown={(event) => {
|
||||
if (suspended) return;
|
||||
if (event.key === "Escape") {
|
||||
@@ -314,8 +359,8 @@ export function MemoryNoteComposer({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="memory-note-heading">
|
||||
<h2 id="memory-note-title">{copy("添加记忆")}</h2>
|
||||
<header className="memory-add-heading">
|
||||
<h2 id="memory-add-title">{copy("添加记忆")}</h2>
|
||||
<button
|
||||
className="quiet"
|
||||
type="button"
|
||||
@@ -333,11 +378,11 @@ export function MemoryNoteComposer({
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<label className="memory-note-label" htmlFor="memory-note-text">
|
||||
<label className="memory-add-label" htmlFor="memory-add-text">
|
||||
{copy("记下这件事")}
|
||||
</label>
|
||||
<textarea
|
||||
id="memory-note-text"
|
||||
id="memory-add-text"
|
||||
rows={5}
|
||||
maxLength={12000}
|
||||
value={text}
|
||||
@@ -345,7 +390,7 @@ export function MemoryNoteComposer({
|
||||
placeholder={copy("例如:项目周会不排在周五。")}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
/>
|
||||
<p className="memory-note-model">
|
||||
<p className="memory-add-model">
|
||||
{copy("整理模型:")}
|
||||
{model?.name ??
|
||||
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
|
||||
@@ -375,58 +420,49 @@ export function MemoryNoteComposer({
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{notes.length > 0 && (
|
||||
{additions.length > 0 && (
|
||||
<section
|
||||
className="memory-note-recent"
|
||||
className="memory-add-recent"
|
||||
aria-label={copy("最近添加")}
|
||||
>
|
||||
<h3>{copy("最近添加")}</h3>
|
||||
<ul>
|
||||
{notes.map((note) => (
|
||||
<li key={note.evidence_id}>
|
||||
<p>{note.text}</p>
|
||||
<small>{copy(states[note.state])}</small>
|
||||
{memoryWriteErrorText(note.error) && (
|
||||
{additions.map(({ requestId, receipt }) => (
|
||||
<li key={requestId}>
|
||||
<p>{receipt.text}</p>
|
||||
<small>{copy(states[receipt.state])}</small>
|
||||
{memoryWriteErrorText(receipt.error) && (
|
||||
<p className="error-banner">
|
||||
{copy(memoryWriteErrorText(note.error) ?? "")}
|
||||
{copy(memoryWriteErrorText(receipt.error) ?? "")}
|
||||
</p>
|
||||
)}
|
||||
{note.error === "hy_memory_model_payment_required" && (
|
||||
{receipt.error === "hy_memory_model_payment_required" && (
|
||||
<p className="error-banner">
|
||||
{copy(
|
||||
"模型服务要求检查余额或付款状态,原话仍已保存。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="memory-note-actions">
|
||||
{note.memory_ids.map((id, index) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className="quiet"
|
||||
onClick={() => {
|
||||
close();
|
||||
onOpenMemory(id);
|
||||
}}
|
||||
>
|
||||
{copy("查看记忆")}
|
||||
{note.memory_ids.length > 1 ? ` ${index + 1}` : ""}
|
||||
</button>
|
||||
))}
|
||||
{(note.state === "failed" ||
|
||||
note.state === "waiting_for_model" ||
|
||||
note.state === "waiting_for_permission") && (
|
||||
<button
|
||||
type="button"
|
||||
className="quiet"
|
||||
disabled={busy}
|
||||
onClick={() => void retry(note)}
|
||||
>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
{copy("重新整理")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{receipt.memory_ids.length > 0 && (
|
||||
<div className="memory-add-actions">
|
||||
{receipt.memory_ids.map((id, index) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className="quiet"
|
||||
onClick={() => {
|
||||
close();
|
||||
onOpenMemory(id);
|
||||
}}
|
||||
>
|
||||
{copy("查看记忆")}
|
||||
{receipt.memory_ids.length > 1
|
||||
? ` ${index + 1}`
|
||||
: ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -84,11 +84,11 @@ vi.mock("./api", async (importOriginal) => ({
|
||||
getModelProfiles: reads.profiles,
|
||||
}));
|
||||
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
|
||||
vi.mock("./MemoryNoteComposer", () => ({
|
||||
MemoryNoteComposer: ({
|
||||
vi.mock("./MemoryAddComposer", () => ({
|
||||
MemoryAddComposer: ({
|
||||
onPendingChange,
|
||||
}: {
|
||||
onPendingChange?(note: {
|
||||
onPendingChange?(addition: {
|
||||
evidenceId: string;
|
||||
text: string;
|
||||
state: string;
|
||||
@@ -98,7 +98,7 @@ vi.mock("./MemoryNoteComposer", () => ({
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onPendingChange?.({
|
||||
evidenceId: "note-1",
|
||||
evidenceId: "addition-1",
|
||||
text: "项目周会统一安排在周三上午。",
|
||||
state: "waiting_for_model",
|
||||
})
|
||||
@@ -196,7 +196,7 @@ function setup() {
|
||||
reads.profiles.mockResolvedValue([]);
|
||||
}
|
||||
|
||||
it("keeps a saved note visible while it waits for model processing", async () => {
|
||||
it("keeps an added memory visible while it waits for model processing", async () => {
|
||||
setup();
|
||||
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "补充记忆" }));
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock("./api", () => ({
|
||||
apiErrorCode: () => null,
|
||||
}));
|
||||
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
|
||||
vi.mock("./MemoryNoteComposer", () => ({ MemoryNoteComposer: () => null }));
|
||||
vi.mock("./MemoryAddComposer", () => ({ MemoryAddComposer: () => null }));
|
||||
vi.mock("./MemoryMap", () => ({
|
||||
MemoryMap: ({
|
||||
hidden,
|
||||
|
||||
@@ -79,9 +79,8 @@ const {
|
||||
getGraphWidth,
|
||||
decideGraphWidth,
|
||||
getModelProfiles,
|
||||
getMemoryNotes,
|
||||
saveMemoryNote,
|
||||
processMemoryNote,
|
||||
getMemoryAddStatus,
|
||||
addMemory,
|
||||
} = vi.hoisted(() => ({
|
||||
apiErrorCode: vi.fn(),
|
||||
getSdkRaw: vi.fn(),
|
||||
@@ -94,9 +93,8 @@ const {
|
||||
getGraphWidth: vi.fn(),
|
||||
decideGraphWidth: vi.fn(),
|
||||
getModelProfiles: vi.fn(),
|
||||
getMemoryNotes: vi.fn(),
|
||||
saveMemoryNote: vi.fn(),
|
||||
processMemoryNote: vi.fn(),
|
||||
getMemoryAddStatus: vi.fn(),
|
||||
addMemory: vi.fn(),
|
||||
}));
|
||||
|
||||
const _LIBRARY_REFRESH_MS = 10_000;
|
||||
@@ -113,9 +111,8 @@ vi.mock("./api", () => ({
|
||||
getGraphWidth,
|
||||
decideGraphWidth,
|
||||
getModelProfiles,
|
||||
getMemoryNotes,
|
||||
saveMemoryNote,
|
||||
processMemoryNote,
|
||||
getMemoryAddStatus,
|
||||
addMemory,
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -171,7 +168,7 @@ describe("MemorySpace", () => {
|
||||
getModelProfiles.mockResolvedValue([
|
||||
{ profile_id: "profile-1", is_active: true },
|
||||
]);
|
||||
getMemoryNotes.mockResolvedValue([]);
|
||||
getMemoryAddStatus.mockRejectedValue(new Error("not found"));
|
||||
setExtractionWindow.mockResolvedValue({ window_preset: "1y" });
|
||||
manualExtract.mockResolvedValue({
|
||||
promoted: 25,
|
||||
@@ -197,8 +194,7 @@ describe("MemorySpace", () => {
|
||||
setExtractionWindow,
|
||||
manualExtract,
|
||||
decideGraphWidth,
|
||||
saveMemoryNote,
|
||||
processMemoryNote,
|
||||
addMemory,
|
||||
])
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
}
|
||||
@@ -281,7 +277,7 @@ describe("MemorySpace", () => {
|
||||
const view = render(<MemorySpace />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
const draft = screen.getByLabelText("记下这件事") as HTMLTextAreaElement;
|
||||
await userEvent.type(draft, "Unsent note");
|
||||
await userEvent.type(draft, "Unsent draft");
|
||||
view.rerender(<MemorySpace active={false} />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
@@ -293,7 +289,7 @@ describe("MemorySpace", () => {
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
view.rerender(<MemorySpace />);
|
||||
expect(screen.getByLabelText("记下这件事")).toBe(draft);
|
||||
expect(draft.value).toBe("Unsent note");
|
||||
expect(draft.value).toBe("Unsent draft");
|
||||
expectNoTeachingWrites();
|
||||
});
|
||||
});
|
||||
@@ -801,7 +797,7 @@ describe("MemorySpace", () => {
|
||||
expect(screen.queryByText(/revision|evidence_ids/)).toBeNull();
|
||||
});
|
||||
|
||||
it("hands a saved answer to HY as a note instead of writing the memory itself", async () => {
|
||||
it("hands a saved answer to HY as an added memory instead of writing the memory itself", async () => {
|
||||
askMemoriesStream.mockImplementation(async function* () {
|
||||
yield {
|
||||
type: "final",
|
||||
@@ -821,7 +817,7 @@ describe("MemorySpace", () => {
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveMemoryNote).toHaveBeenCalledWith(
|
||||
expect(addMemory).toHaveBeenCalledWith(
|
||||
{
|
||||
request_id: expect.any(String),
|
||||
text: "Grounded answer",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
addMemory,
|
||||
apiErrorCode,
|
||||
askMemoriesStream,
|
||||
deleteSdkMemory,
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
getSdkMemoryLibrary,
|
||||
manualExtract,
|
||||
retryUnknownMemoryProcessing,
|
||||
saveMemoryNote,
|
||||
setExtractionWindow,
|
||||
updateSdkMemory,
|
||||
} from "./api";
|
||||
@@ -60,10 +60,7 @@ import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
|
||||
import "./r6-memory.css";
|
||||
import {
|
||||
MemoryNoteComposer,
|
||||
type PendingMemoryNote,
|
||||
} from "./MemoryNoteComposer";
|
||||
import { MemoryAddComposer, type PendingMemoryAdd } from "./MemoryAddComposer";
|
||||
import type { MemoryArrival } from "./memory-arrival-state";
|
||||
import {
|
||||
blockReasonLabel,
|
||||
@@ -326,9 +323,7 @@ export function MemorySpace({
|
||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingNote, setPendingNote] = useState<PendingMemoryNote | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingAdd, setPendingAdd] = useState<PendingMemoryAdd | null>(null);
|
||||
const [checkingSetup, setCheckingSetup] = useState(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Source authority changes invalidate an in-flight setup snapshot.
|
||||
useEffect(() => {
|
||||
@@ -553,11 +548,11 @@ export function MemorySpace({
|
||||
async function saveAnswer(draft: { content: string }) {
|
||||
// HY decides what becomes a memory; the reader choosing to keep the
|
||||
// answer is what makes it their words for HY to read.
|
||||
// Saving an answer writes a memory note, so it needs the same ui.action /
|
||||
// ui.result pair as the composer's note save.
|
||||
// Saving an answer adds a memory, so it needs the same ui.action /
|
||||
// ui.result pair as the composer's save.
|
||||
const trace = traceUiAction("memory", "submit");
|
||||
try {
|
||||
await saveMemoryNote(
|
||||
await addMemory(
|
||||
{
|
||||
request_id: crypto.randomUUID(),
|
||||
text: draft.content,
|
||||
@@ -569,8 +564,8 @@ export function MemorySpace({
|
||||
} catch (reason) {
|
||||
trace.done(writeTraceStatus(reason), {
|
||||
reason: isDefiniteRefusal(reason)
|
||||
? "answer_note_save_failed"
|
||||
: "answer_note_save_result_unknown",
|
||||
? "answer_memory_add_failed"
|
||||
: "answer_memory_add_result_unknown",
|
||||
});
|
||||
throw reason;
|
||||
}
|
||||
@@ -770,11 +765,11 @@ export function MemorySpace({
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
<MemoryNoteComposer
|
||||
<MemoryAddComposer
|
||||
suspended={!active}
|
||||
onPendingChange={setPendingNote}
|
||||
onPendingChange={setPendingAdd}
|
||||
onSaved={() => {
|
||||
// A finished note is a new memory: re-read the page now rather
|
||||
// A finished addition is a new memory: re-read the page now rather
|
||||
// than leave it for the next poll.
|
||||
bumpSdkMap();
|
||||
}}
|
||||
@@ -955,13 +950,13 @@ export function MemorySpace({
|
||||
hasMemories={(sdkLibrary.page?.total ?? 0) > 0}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
{simplified && pendingNote ? (
|
||||
<p className="memory-pending-note" role="status">
|
||||
{simplified && pendingAdd ? (
|
||||
<p className="memory-pending-add" role="status">
|
||||
<span>
|
||||
{t("memory:原话已保存,正在等待整理:")}
|
||||
{pendingNote.text}
|
||||
{pendingAdd.text}
|
||||
</span>
|
||||
{pendingNote.state === "waiting_for_model" ? (
|
||||
{pendingAdd.state === "waiting_for_model" ? (
|
||||
<button type="button" onClick={onConfigureModel}>
|
||||
{t("memory:配置整理模型")}
|
||||
</button>
|
||||
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
getImportJob,
|
||||
getImportJobs,
|
||||
getLiveSyncHealth,
|
||||
getMemoryNoteOperation,
|
||||
getMemoryNotes,
|
||||
getMemoryAddStatus,
|
||||
getModelCreationOperation,
|
||||
getModelProfiles,
|
||||
getSourceImportReceipt,
|
||||
@@ -258,11 +257,13 @@ describe("source import receipt transport validation", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("memory-note list transport validation", () => {
|
||||
it("accepts a list of valid note receipts", async () => {
|
||||
const note = {
|
||||
describe("memory-add status transport validation", () => {
|
||||
const requestId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
it("reads a valid receipt from the additions status route", async () => {
|
||||
const receipt = {
|
||||
evidence_id: "11111111-1111-4111-8111-111111111111",
|
||||
text: "user note",
|
||||
text: "user memory",
|
||||
scope: "all-apps",
|
||||
created_at: "2026-09-13T00:00:00Z",
|
||||
state: "pending",
|
||||
@@ -270,23 +271,27 @@ describe("memory-note list transport validation", () => {
|
||||
memory_ids: [],
|
||||
error: null,
|
||||
};
|
||||
fake.get.mockResolvedValue({ data: [note] });
|
||||
await expect(getMemoryNotes()).resolves.toEqual([note]);
|
||||
});
|
||||
|
||||
it("rejects a malformed note before the composer can crash on it", async () => {
|
||||
fake.get.mockResolvedValue({ data: [null] });
|
||||
await expect(getMemoryNotes()).rejects.toThrow(
|
||||
"memory_note_receipt_invalid",
|
||||
fake.get.mockResolvedValue({ data: receipt });
|
||||
await expect(getMemoryAddStatus(requestId)).resolves.toEqual(receipt);
|
||||
expect(fake.get).toHaveBeenLastCalledWith(
|
||||
`/api/v2/memories/additions/${requestId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed operation receipt before it reaches the composer", async () => {
|
||||
it("rejects a malformed status receipt before it reaches the composer", async () => {
|
||||
fake.get.mockResolvedValue({ data: { state: "pending" } });
|
||||
await expect(getMemoryNoteOperation("operation-1")).rejects.toThrow(
|
||||
"memory_note_receipt_invalid",
|
||||
await expect(getMemoryAddStatus(requestId)).rejects.toThrow(
|
||||
"memory_add_receipt_invalid",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a non-UUID request id without a request", async () => {
|
||||
fake.get.mockClear();
|
||||
await expect(getMemoryAddStatus("operation-1")).rejects.toThrow(
|
||||
"request_id_invalid",
|
||||
);
|
||||
expect(fake.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("history file recovery transport validation", () => {
|
||||
|
||||
+21
-47
@@ -14,9 +14,9 @@ import {
|
||||
type ImportJob,
|
||||
type LiveSyncHealth,
|
||||
type ManualExtractReceipt,
|
||||
type MemoryAddInput,
|
||||
type MemoryAddReceipt,
|
||||
type MemoryGraphResponse,
|
||||
type MemoryNoteInput,
|
||||
type MemoryNoteReceipt,
|
||||
type MemoryProcessingSummary,
|
||||
type ModelCreationOperation,
|
||||
type ModelProbeOperationReceipt,
|
||||
@@ -42,18 +42,19 @@ import {
|
||||
// stays here is the transport adapter: axios wiring, recovery clients, and the
|
||||
// UI-facing error mapping.
|
||||
import {
|
||||
buildMemoryNoteSaveRequest,
|
||||
buildMemoryAddRequest,
|
||||
buildModelProfileCreateRequest,
|
||||
buildModelProfileUpdateRequest,
|
||||
buildSourceImportRequest,
|
||||
buildSourceSyncRequest,
|
||||
classifyModelRemove,
|
||||
hasSourceSyncTarget,
|
||||
memoryAddStatusPath,
|
||||
normalizeManualExtractEvidence,
|
||||
parseExtractionSettingsReceipt,
|
||||
parseGraphWidthDecisionReceipt,
|
||||
parseManualExtractReceipt,
|
||||
parseMemoryNoteReceipt,
|
||||
parseMemoryAddReceipt,
|
||||
parseModelProfileReceipt,
|
||||
SourceSyncInputError,
|
||||
} from "@one-memory/operations";
|
||||
@@ -149,55 +150,29 @@ export async function getCallReferencesRequest(
|
||||
|
||||
export type { HealthResponse };
|
||||
|
||||
export async function saveMemoryNote(
|
||||
note: MemoryNoteInput,
|
||||
/** Add one memory directly (`POST /api/v2/memories`). */
|
||||
export async function addMemory(
|
||||
input: MemoryAddInput,
|
||||
traceId?: string,
|
||||
): Promise<MemoryNoteReceipt> {
|
||||
const request = buildMemoryNoteSaveRequest(note);
|
||||
const response = await client.post<MemoryNoteReceipt>(
|
||||
): Promise<MemoryAddReceipt> {
|
||||
const request = buildMemoryAddRequest(input);
|
||||
const response = await client.post<MemoryAddReceipt>(
|
||||
request.path,
|
||||
request.body,
|
||||
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
|
||||
);
|
||||
const receipt = parseMemoryNoteReceipt(response.data);
|
||||
if (receipt === null) throw new Error("memory_note_receipt_invalid");
|
||||
const receipt = parseMemoryAddReceipt(response.data);
|
||||
if (receipt === null) throw new Error("memory_add_receipt_invalid");
|
||||
return receipt;
|
||||
}
|
||||
|
||||
export async function getMemoryNotes(): Promise<MemoryNoteReceipt[]> {
|
||||
const response = await client.get<unknown>("/api/v1/memory-notes");
|
||||
if (!Array.isArray(response.data))
|
||||
throw new Error("memory_note_receipt_invalid");
|
||||
const notes = response.data.map(parseMemoryNoteReceipt);
|
||||
if (notes.some((note) => note === null))
|
||||
throw new Error("memory_note_receipt_invalid");
|
||||
return notes as MemoryNoteReceipt[];
|
||||
}
|
||||
|
||||
export async function getMemoryNoteOperation(
|
||||
/** Status of one addition, keyed by the request id it was submitted with. */
|
||||
export async function getMemoryAddStatus(
|
||||
requestId: string,
|
||||
): Promise<MemoryNoteReceipt> {
|
||||
const response = await client.get<unknown>(
|
||||
`/api/v1/memory-notes/operations/${encodeURIComponent(requestId)}`,
|
||||
);
|
||||
const receipt = parseMemoryNoteReceipt(response.data);
|
||||
if (receipt === null) throw new Error("memory_note_receipt_invalid");
|
||||
return receipt;
|
||||
}
|
||||
|
||||
export async function processMemoryNote(
|
||||
id: string,
|
||||
traceId?: string,
|
||||
): Promise<MemoryNoteReceipt> {
|
||||
const response = await client.post<MemoryNoteReceipt>(
|
||||
`/api/v1/memory-notes/${encodeURIComponent(id)}/process`,
|
||||
{},
|
||||
// Starting processing is a write; carry the trace id so the ui.action /
|
||||
// ui.result pair joins the kernel legs in one chain.
|
||||
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
|
||||
);
|
||||
const receipt = parseMemoryNoteReceipt(response.data);
|
||||
if (receipt === null) throw new Error("memory_note_receipt_invalid");
|
||||
): Promise<MemoryAddReceipt> {
|
||||
const response = await client.get<unknown>(memoryAddStatusPath(requestId));
|
||||
const receipt = parseMemoryAddReceipt(response.data);
|
||||
if (receipt === null) throw new Error("memory_add_receipt_invalid");
|
||||
return receipt;
|
||||
}
|
||||
|
||||
@@ -1037,9 +1012,8 @@ export async function setExtractionWindow(
|
||||
{ window_preset: windowPreset },
|
||||
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
|
||||
);
|
||||
// The CLI validates this receipt (`ui-write-flows-extended.ts`
|
||||
// `executeExtractionWindowSet` -> `ui_write_receipt_invalid`); the Web stored
|
||||
// the raw body, so a malformed 200 became a saved preset.
|
||||
// The receipt is validated before it is trusted; the Web used to store the
|
||||
// raw body, so a malformed 200 became a saved preset.
|
||||
const receipt = parseExtractionSettingsReceipt(response.data, windowPreset);
|
||||
if (receipt === null) throw new Error("extraction_settings_receipt_invalid");
|
||||
return receipt;
|
||||
|
||||
@@ -523,7 +523,7 @@ button.rail-button.active::before {
|
||||
font-size: 12px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
.memory-note-trigger {
|
||||
.memory-add-trigger {
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
grid-column: 3;
|
||||
@@ -1430,12 +1430,12 @@ button.map-related {
|
||||
color: var(--dg);
|
||||
border: 1px solid var(--dg-bd);
|
||||
}
|
||||
.memory-note-sheet .memory-note-recent {
|
||||
.memory-add-sheet .memory-add-recent {
|
||||
background: transparent;
|
||||
padding: 16px 0 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.memory-note-recent li {
|
||||
.memory-add-recent li {
|
||||
background: transparent;
|
||||
}
|
||||
.memory-query-bar {
|
||||
@@ -1935,7 +1935,7 @@ button.map-related {
|
||||
grid-row: 1;
|
||||
justify-self: center;
|
||||
}
|
||||
.memory-note-trigger {
|
||||
.memory-add-trigger {
|
||||
grid-column: 3;
|
||||
}
|
||||
.desktop-shell .core-memory-map {
|
||||
@@ -2053,7 +2053,7 @@ button.map-related {
|
||||
.memory-instrument-header h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
.memory-note-trigger {
|
||||
.memory-add-trigger {
|
||||
font-size: 12px;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,7 @@ export type PollKey =
|
||||
| "applications"
|
||||
| "calls"
|
||||
| "model-profiles"
|
||||
| "note-evidence"
|
||||
| "note-processing"
|
||||
| "memory-add-processing"
|
||||
| "health";
|
||||
|
||||
export interface PollTask {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
font-size: 13px;
|
||||
color: #637786;
|
||||
}
|
||||
.r6-memory .memory-pending-note {
|
||||
.r6-memory .memory-pending-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -36,11 +36,11 @@
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-memory .memory-pending-note span {
|
||||
.r6-memory .memory-pending-add span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.r6-memory .memory-pending-note button {
|
||||
.r6-memory .memory-pending-add button {
|
||||
flex: none;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
@@ -51,7 +51,7 @@
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.r6-memory .memory-note-trigger {
|
||||
.r6-memory .memory-add-trigger {
|
||||
margin-left: auto;
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
@@ -59,7 +59,7 @@
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
.r6-memory .memory-note-trigger > svg {
|
||||
.r6-memory .memory-add-trigger > svg {
|
||||
display: none;
|
||||
}
|
||||
.r6-memory .core-memory-map {
|
||||
@@ -267,7 +267,7 @@
|
||||
.r6-memory .map-library {
|
||||
padding: 16px 10px;
|
||||
}
|
||||
.r6-memory .memory-note-trigger {
|
||||
.r6-memory .memory-add-trigger {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3270,22 +3270,22 @@ button.onboarding-skip {
|
||||
border-style: dashed;
|
||||
color: var(--tx2);
|
||||
}
|
||||
.memory-note-trigger,
|
||||
.memory-note-heading,
|
||||
.memory-note-actions,
|
||||
.memory-note-consent {
|
||||
.memory-add-trigger,
|
||||
.memory-add-heading,
|
||||
.memory-add-actions,
|
||||
.memory-add-consent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.memory-note-heading {
|
||||
.memory-add-heading {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.memory-note-sheet {
|
||||
.memory-add-sheet {
|
||||
max-height: calc(100dvh - 48px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.memory-note-sheet textarea {
|
||||
.memory-add-sheet textarea {
|
||||
width: 100%;
|
||||
min-height: 128px;
|
||||
resize: vertical;
|
||||
@@ -3296,19 +3296,19 @@ button.onboarding-skip {
|
||||
border: 1px solid var(--bd2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.memory-note-label {
|
||||
.memory-add-label {
|
||||
display: block;
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
.memory-note-model {
|
||||
.memory-add-model {
|
||||
color: var(--tx2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.memory-note-consent {
|
||||
.memory-add-consent {
|
||||
align-items: flex-start;
|
||||
font-size: 13px;
|
||||
}
|
||||
.memory-note-consent input {
|
||||
.memory-add-consent input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
@@ -3317,18 +3317,18 @@ button.onboarding-skip {
|
||||
margin-top: 4px;
|
||||
accent-color: var(--ac);
|
||||
}
|
||||
.memory-note-sheet {
|
||||
.memory-add-sheet {
|
||||
color: var(--tx);
|
||||
background: rgba(248, 250, 252, 0.96);
|
||||
}
|
||||
.memory-note-consent {
|
||||
.memory-add-consent {
|
||||
line-height: 1.7;
|
||||
}
|
||||
.memory-note-trigger {
|
||||
.memory-add-trigger {
|
||||
justify-self: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.memory-note-toast {
|
||||
.memory-add-toast {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
left: 50%;
|
||||
@@ -3357,33 +3357,33 @@ button.onboarding-skip {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
.memory-note-recent {
|
||||
.memory-add-recent {
|
||||
border-top: 1px solid var(--bd);
|
||||
margin-top: 20px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.memory-note-recent h3 {
|
||||
.memory-add-recent h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
.memory-note-recent ul {
|
||||
.memory-add-recent ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.memory-note-recent li {
|
||||
.memory-add-recent li {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
.memory-note-recent p {
|
||||
.memory-add-recent p {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.memory-note-recent small {
|
||||
.memory-add-recent small {
|
||||
color: var(--tx2);
|
||||
}
|
||||
.memory-note-actions {
|
||||
.memory-add-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ describe("ui trace write coverage", () => {
|
||||
expect(memory).toMatch(/updateSdkMemory\([\s\S]{0,160}trace\.traceId/);
|
||||
expect(memory).toMatch(/deleteSdkMemory\([\s\S]{0,160}trace\.traceId/);
|
||||
// The composer's save is the native four-level sample.
|
||||
expect(source("MemoryNoteComposer.tsx")).toMatch(
|
||||
/saveMemoryNote\([\s\S]{0,200}trace\.traceId/,
|
||||
expect(source("MemoryAddComposer.tsx")).toMatch(
|
||||
/addMemory\([\s\S]{0,200}trace\.traceId/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -225,11 +225,11 @@ describe("ui trace write coverage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("traces the answer note save", () => {
|
||||
// `saveAnswer` writes a memory note, so it must form a four-line chain.
|
||||
it("traces the answer memory add", () => {
|
||||
// `saveAnswer` adds a memory, so it must form a four-line chain.
|
||||
const view = source("MemorySpace.tsx");
|
||||
expect(view).toMatch(
|
||||
/saveMemoryNote\(\s*\{[\s\S]{0,240}?scope: "all-apps",\s*\},?\s*trace\.traceId,?\s*\)/,
|
||||
/addMemory\(\s*\{[\s\S]{0,240}?scope: "all-apps",\s*\},?\s*trace\.traceId,?\s*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -271,19 +271,14 @@ describe("ui trace write coverage", () => {
|
||||
expect(body).toContain("X-One-Memory-Trace-Id");
|
||||
});
|
||||
|
||||
it("traces the memory-note process write", () => {
|
||||
// "重新整理" starts note processing: a non-GET write that had no trace.
|
||||
const composer = source("MemoryNoteComposer.tsx");
|
||||
expect(composer).toMatch(/traceUiAction\("memory", "retry"\)/);
|
||||
expect(composer).toMatch(
|
||||
/processMemoryNote\(note\.evidence_id, trace\.traceId\)/,
|
||||
);
|
||||
it("has no separate memory-add process write", () => {
|
||||
// Processing an addition is not a client write: activating a model
|
||||
// readmits parked work. The composer must not grow a retry route again.
|
||||
const composer = source("MemoryAddComposer.tsx");
|
||||
expect(composer).not.toMatch(/traceUiAction\("memory", "retry"\)/);
|
||||
const api = source("api.ts");
|
||||
const start = api.indexOf("export async function processMemoryNote(");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const body = api.slice(start, start + 600);
|
||||
expect(body).toContain("traceId?: string");
|
||||
expect(body).toContain("X-One-Memory-Trace-Id");
|
||||
expect(api).not.toContain("/process`");
|
||||
expect(api).not.toContain("memory-notes");
|
||||
});
|
||||
|
||||
it("traces every model-profile write through the hook's single choke point", () => {
|
||||
|
||||
@@ -39,7 +39,8 @@ memhub status
|
||||
4. 校验 HTTPS URL、重定向域名、文件大小与 SHA-256;
|
||||
5. 安全解压并交叉验证内部 manifest 的版本、build、commit、协议和逐文件哈希;
|
||||
6. 调用现有安装事务,注册用户级服务和受管 CLI 链接;
|
||||
7. 失败时回滚,并保留当前 Runtime 和 Memory 数据。
|
||||
7. 记忆数据迁移开始前失败会回滚,并保留当前 Runtime 和 Memory 数据;迁移开始后失败
|
||||
保留新版本、拒绝启动(不会用旧版本打开数据),并输出迁移日志与反馈地址。
|
||||
|
||||
不带 `--yes` 的 `memhub install` 只下载较小的 Release manifest 并展示计划;显式确认后才
|
||||
下载完整 Runtime。npm `postinstall` 不下载 Runtime、不注册服务、不修改 shell profile。
|
||||
|
||||
@@ -32,87 +32,94 @@ node apps/cli/dist/one-memory.cjs doctor
|
||||
|
||||
`status` 显示 Runtime 是否运行;`doctor` 按诊断口径做只读检查(默认不修复)。
|
||||
|
||||
## 2. 首次接入(init)—— 没有独立的「授权」步骤
|
||||
## 2. 首次接入(init / sources connect)—— 没有独立的「授权」步骤
|
||||
|
||||
```bash
|
||||
# 先看本机有哪些宿主可接入
|
||||
# 在终端里:交互向导(选来源 → 是否开启处理 → 配置模型)
|
||||
# 非终端或加 --json:只列出可接入的来源和下一步命令
|
||||
node apps/cli/dist/one-memory.cjs init
|
||||
|
||||
# 指定来源,生成接入计划(默认只出计划)
|
||||
node apps/cli/dist/one-memory.cjs init --source codex
|
||||
# 只授权(处理保持冻结:不导入历史、不扫描新会话)
|
||||
node apps/cli/dist/one-memory.cjs sources connect codex
|
||||
|
||||
# 接入并同时导入历史
|
||||
node apps/cli/dist/one-memory.cjs init --source codex --import
|
||||
# 接入并同时导入历史、开启持续同步;输出 request id
|
||||
node apps/cli/dist/one-memory.cjs sources connect codex --import
|
||||
|
||||
# 只看会做什么,不真的做
|
||||
node apps/cli/dist/one-memory.cjs init --source codex --import --dry-run
|
||||
# 一次接入所有已检测到的来源
|
||||
node apps/cli/dist/one-memory.cjs sources connect --all-detected --import
|
||||
```
|
||||
|
||||
**接入即授权**:没有单独的 authorize 步骤。
|
||||
**接入即授权**:没有单独的 authorize 步骤,也没有计划文件。目录来自环境变量覆盖
|
||||
(如 `DSH_HOME`)的来源必须加 `--confirm-overrides`,否则被拒绝(或在
|
||||
`--all-detected` 时跳过并列出)。用同一个 `--request-id <uuid>` 重试不会重复导入。
|
||||
|
||||
## 3. 导入历史与看进度
|
||||
## 3. 看导入进度
|
||||
|
||||
```bash
|
||||
node apps/cli/dist/one-memory.cjs setup plan --source codex # 生成导入计划
|
||||
node apps/cli/dist/one-memory.cjs sources import --plan-file <计划文件> --confirm
|
||||
node apps/cli/dist/one-memory.cjs progress # 看导入进度
|
||||
node apps/cli/dist/one-memory.cjs sources receipt <request-id> # 接入回执
|
||||
node apps/cli/dist/one-memory.cjs progress # 导入与生成进度
|
||||
```
|
||||
|
||||
真机观察:授权 codex 后任务运行中并持续落库(stored 从 14,437 涨到 62,634)。
|
||||
|
||||
## 4. 扫描(两阶段)
|
||||
## 4. 立即扫描一个来源
|
||||
|
||||
```bash
|
||||
node apps/cli/dist/one-memory.cjs scan # 预览
|
||||
node apps/cli/dist/one-memory.cjs scan --yes --source codex # 执行(必须带 --source)
|
||||
node apps/cli/dist/one-memory.cjs scan --source codex # 直接执行,返回导入任务
|
||||
```
|
||||
|
||||
不带 `--yes` 只出计划;执行时给裸路径会明确拒绝(Runtime 按来源寻址)。
|
||||
来源必须已接入并开启持续同步,否则返回 `live_sync_unavailable`(exit 1)。
|
||||
|
||||
## 5. 登记扫描目录(path)
|
||||
## 5. 登记扫描目录(scan-roots,旧名 path 仍可用)
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/scanme
|
||||
node apps/cli/dist/one-memory.cjs path list
|
||||
node apps/cli/dist/one-memory.cjs path add --root /tmp/scanme --source codex --yes
|
||||
node apps/cli/dist/one-memory.cjs path list # 显示刚登记的,revision 0→1
|
||||
node apps/cli/dist/one-memory.cjs path remove --root /tmp/scanme --yes
|
||||
node apps/cli/dist/one-memory.cjs scan-roots list
|
||||
node apps/cli/dist/one-memory.cjs scan-roots add --root /tmp/scanme --source codex
|
||||
node apps/cli/dist/one-memory.cjs scan-roots list # 显示刚登记的,revision 0→1
|
||||
node apps/cli/dist/one-memory.cjs scan-roots remove --root /tmp/scanme
|
||||
```
|
||||
|
||||
行为已验证:重复登记返回 **409**;删除不存在的返回 **404**。
|
||||
行为已验证:重复登记返回 **409**;删除不存在的返回 **404**(都是 exit 1,带 code)。
|
||||
|
||||
## 6. 看宿主适配器(adapter)
|
||||
|
||||
```bash
|
||||
node apps/cli/dist/one-memory.cjs adapter list
|
||||
node apps/cli/dist/one-memory.cjs adapter probe --source codex
|
||||
```
|
||||
|
||||
输出每个 harness 的 available / authorized / live_sync / recall 能力。
|
||||
|
||||
`adapter probe` **目前会拒绝**(Runtime 未提供探测端点),不是参数问题。
|
||||
|
||||
## 7. 写入记忆与查任务(write → task)
|
||||
## 7. 添加记忆与写入对话
|
||||
|
||||
```bash
|
||||
node apps/cli/dist/one-memory.cjs write --session <会话ID> --from 1 --to 2 --evidence <证据ID> --json
|
||||
# 返回 request_id(state=accepted)
|
||||
# 新增一条结论式记忆(与 App 的「添加记忆」相同)
|
||||
node apps/cli/dist/one-memory.cjs memories add "我更喜欢用 pnpm" --json
|
||||
node apps/cli/dist/one-memory.cjs memories add-status <request_id> --json
|
||||
|
||||
node apps/cli/dist/one-memory.cjs task --id <request_id> --json
|
||||
# 提交一段 OpenAI messages 对话,异步加工成记忆
|
||||
node apps/cli/dist/one-memory.cjs write submit --session <会话ID> --messages-file <文件> --json
|
||||
node apps/cli/dist/one-memory.cjs write status <request_id> --json
|
||||
```
|
||||
|
||||
写入异步完成;`unknown` 表示结果待定,**不可据此重发**。
|
||||
两条都返回 request id;它是幂等键,结果未知时用**同一个** `--request-id` 重试不会重复写入。
|
||||
|
||||
## 8. 召回(recall)
|
||||
|
||||
`recall` 两条路径(raw / memory)此前已真机验证,命令可继续使用。
|
||||
`recall` 两条路径(raw / memory)此前已真机验证。文本输出会标明每条是「原始对话摘录」
|
||||
还是「记忆」。
|
||||
|
||||
## 9. 备份
|
||||
|
||||
```bash
|
||||
node apps/cli/dist/one-memory.cjs backup create --target <data目录内的路径> --yes --json
|
||||
node apps/cli/dist/one-memory.cjs backup create --target <数据目录>/backups/<文件名> --json
|
||||
node apps/cli/dist/one-memory.cjs backup restore --source <数据目录>/backups/<文件名> # 预览
|
||||
node apps/cli/dist/one-memory.cjs backup restore --source <数据目录>/backups/<文件名> --yes --confirm <数据目录>/backups/<文件名> # 执行
|
||||
```
|
||||
|
||||
目标必须在数据目录内(否则拒绝);产物已验证 540,672 字节、权限 owner-only。
|
||||
备份只包含主库(来源、写入队列、配置),**不包含记忆本身**(`hy-memory/`)。目标必须在
|
||||
数据目录内(否则拒绝),放在 `backups/` 下才能被 restore 使用;restore 需 Runtime 已停止。
|
||||
|
||||
## 10. 宿主接入(host)
|
||||
|
||||
@@ -129,11 +136,19 @@ node apps/cli/dist/one-memory.cjs host uninstall --host codex --approve <fingerp
|
||||
## 11. 持续同步控制
|
||||
|
||||
```bash
|
||||
node apps/cli/dist/one-memory.cjs sync status --host codex
|
||||
node apps/cli/dist/one-memory.cjs sync pause --host codex
|
||||
node apps/cli/dist/one-memory.cjs sync resume --host codex
|
||||
node apps/cli/dist/one-memory.cjs sync status
|
||||
node apps/cli/dist/one-memory.cjs sync pause --source codex
|
||||
node apps/cli/dist/one-memory.cjs sync resume --source codex
|
||||
```
|
||||
|
||||
## 确认约定
|
||||
|
||||
只有不可逆操作需要 `--yes`:`memories delete`、`models remove`、`delete-data`、
|
||||
`backup restore`、`graph-width --decision rebuild_with_new`。不加时只说明将要做什么,不做任何
|
||||
改动(exit 0)。`delete-data` 和 `backup restore` 还要用 `--confirm <路径>` 再写一遍目标(数据目录 /
|
||||
备份文件),只加 `--yes` 会被拒绝。install / upgrade / uninstall / service-install / service-uninstall 不加 `--yes`
|
||||
时只出计划。其它写命令直接执行。
|
||||
|
||||
## 12. 卸载(保留数据)
|
||||
|
||||
```bash
|
||||
@@ -144,7 +159,6 @@ node apps/cli/dist/one-memory.cjs uninstall --install-root <root> --home <home>
|
||||
|
||||
## 已知会明确拒绝的(不是 bug)
|
||||
|
||||
- `adapter probe`:Runtime 无探测端点。
|
||||
- `service-install/service-uninstall --yes`(在隔离环境):需真实用户域(launchctl),失败时会正确回滚、不留半成品。
|
||||
- 某些命令不接受 `--json`(如 `start`、`install`、`uninstall`)——契约里该命令没有这个选项,带了会退 2。
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.5",
|
||||
"release": {
|
||||
"build_number": 31,
|
||||
"build_number": 32,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -1042,13 +1042,16 @@ export interface ManualExtractReceipt {
|
||||
batches_current: number;
|
||||
}
|
||||
|
||||
export interface MemoryNoteInput {
|
||||
/** Body of `POST /api/v2/memories`: add one memory directly. */
|
||||
export interface MemoryAddInput {
|
||||
request_id: string;
|
||||
text: string;
|
||||
scope: "all-apps";
|
||||
}
|
||||
|
||||
export interface MemoryNoteReceipt {
|
||||
/** Receipt of `POST /api/v2/memories` and
|
||||
* `GET /api/v2/memories/additions/{request_id}`. */
|
||||
export interface MemoryAddReceipt {
|
||||
evidence_id: string;
|
||||
text: string;
|
||||
scope: "all-apps";
|
||||
@@ -1066,7 +1069,6 @@ export interface MemoryNoteReceipt {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type SavedMemoryNoteEvidence = MemoryEvidenceExcerpt;
|
||||
export type {
|
||||
BackupRestoreFailureCode,
|
||||
BackupRestoreRequest,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { lstat, open } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
ContextSearchRequest,
|
||||
MemoryNoteReceipt,
|
||||
MemoryAddReceipt,
|
||||
RecallReceipt,
|
||||
RecallRequest,
|
||||
SdkContextSearchResponse,
|
||||
@@ -67,8 +67,8 @@ export type BrokerClientErrorCode =
|
||||
| "context_unavailable"
|
||||
| "context_cancelled"
|
||||
| "context_timeout"
|
||||
| "memory_note_conflict"
|
||||
| "memory_note_invalid"
|
||||
| "memory_add_conflict"
|
||||
| "memory_add_invalid"
|
||||
| "broker_request_failed"
|
||||
| "broker_unavailable";
|
||||
|
||||
@@ -83,9 +83,9 @@ const CLIENT_MESSAGES: Record<BrokerClientErrorCode, string> = {
|
||||
"MemHub context response is invalid or incompatible.",
|
||||
context_unavailable:
|
||||
"MemHub is running, but context search is temporarily unavailable.",
|
||||
memory_note_conflict:
|
||||
memory_add_conflict:
|
||||
"MemHub already holds a different memory under this request_id. Use a new request_id for new content.",
|
||||
memory_note_invalid: "MemHub rejected this memory as invalid.",
|
||||
memory_add_invalid: "MemHub rejected this memory as invalid.",
|
||||
host_credential_unavailable:
|
||||
"MemHub host credential is unavailable. Re-link this installation in MemHub.",
|
||||
host_authorization_required:
|
||||
@@ -226,33 +226,33 @@ export async function grantedToolsThroughBroker(
|
||||
}
|
||||
|
||||
/** Core caps a hand-added memory at 12,000 characters. */
|
||||
export const MEMORY_NOTE_TEXT_MAX = 12_000;
|
||||
export const MEMORY_ADD_TEXT_MAX = 12_000;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** What a host sends to write one hand-added memory. Scope is not a field:
|
||||
* the broker always files the note as cross-app, as the app's 添加记忆 does. */
|
||||
export interface MemoryNoteRequest {
|
||||
* the broker always files the memory as cross-app, as the app's 添加记忆 does. */
|
||||
export interface MemoryAddBrokerRequest {
|
||||
request_id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function isMemoryNoteRequestId(value: unknown): value is string {
|
||||
export function isMemoryAddRequestId(value: unknown): value is string {
|
||||
return typeof value === "string" && UUID.test(value);
|
||||
}
|
||||
|
||||
export function isMemoryNoteText(value: unknown): value is string {
|
||||
export function isMemoryAddText(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length >= 1 &&
|
||||
value.length <= MEMORY_NOTE_TEXT_MAX &&
|
||||
value.length <= MEMORY_ADD_TEXT_MAX &&
|
||||
value.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
/** Exactly `{request_id, text}`; anything else is refused, not dropped. */
|
||||
export function isMemoryNoteRequest(
|
||||
export function isMemoryAddBrokerRequest(
|
||||
value: unknown,
|
||||
): value is MemoryNoteRequest {
|
||||
): value is MemoryAddBrokerRequest {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const body = value as Record<string, unknown>;
|
||||
const keys = Object.keys(body);
|
||||
@@ -260,27 +260,21 @@ export function isMemoryNoteRequest(
|
||||
keys.length === 2 &&
|
||||
keys.includes("request_id") &&
|
||||
keys.includes("text") &&
|
||||
isMemoryNoteRequestId(body.request_id) &&
|
||||
isMemoryNoteText(body.text)
|
||||
isMemoryAddRequestId(body.request_id) &&
|
||||
isMemoryAddText(body.text)
|
||||
);
|
||||
}
|
||||
|
||||
/** Write one hand-added memory through the broker to Core's memory notes.
|
||||
/** Write one hand-added memory through the broker to Core's `POST /api/v2/memories`.
|
||||
*
|
||||
* The same entry the app's 添加记忆 uses. Reusing a request_id returns the
|
||||
* original note rather than saving a second one. */
|
||||
export async function memoryNoteThroughBroker(
|
||||
* original addition rather than saving a second one. */
|
||||
export async function memoryAddThroughBroker(
|
||||
discoveryPath: string,
|
||||
request: MemoryNoteRequest,
|
||||
request: MemoryAddBrokerRequest,
|
||||
options?: BrokerClientOptions,
|
||||
): Promise<MemoryNoteReceipt> {
|
||||
return postThroughBroker(
|
||||
discoveryPath,
|
||||
"memory-notes",
|
||||
request,
|
||||
15_000,
|
||||
options,
|
||||
);
|
||||
): Promise<MemoryAddReceipt> {
|
||||
return postThroughBroker(discoveryPath, "memories", request, 15_000, options);
|
||||
}
|
||||
|
||||
async function brokerBase(
|
||||
@@ -411,7 +405,7 @@ async function postThroughBroker<T>(
|
||||
| "recall"
|
||||
| "context"
|
||||
| "call-events/delivery"
|
||||
| "memory-notes"
|
||||
| "memories"
|
||||
| "diagnostics/trace",
|
||||
request: unknown,
|
||||
timeout: number,
|
||||
@@ -447,13 +441,13 @@ async function postThroughBroker<T>(
|
||||
throw new BrokerClientError("context_unavailable");
|
||||
// A reused request_id with different content is a conflict, not an
|
||||
// outage: the caller must pick a new id for new content.
|
||||
if (endpoint === "memory-notes" && error.response?.status === 409)
|
||||
throw new BrokerClientError("memory_note_conflict");
|
||||
if (endpoint === "memories" && error.response?.status === 409)
|
||||
throw new BrokerClientError("memory_add_conflict");
|
||||
if (
|
||||
endpoint === "memory-notes" &&
|
||||
endpoint === "memories" &&
|
||||
[400, 422].includes(error.response?.status ?? 0)
|
||||
)
|
||||
throw new BrokerClientError("memory_note_invalid");
|
||||
throw new BrokerClientError("memory_add_invalid");
|
||||
if (error.response?.status === 401)
|
||||
throw new BrokerClientError("host_authorization_required");
|
||||
if (error.response?.status === 403) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { MemoryNoteReceipt, RecallRequest } from "@one-memory/contracts";
|
||||
import type { MemoryAddReceipt, RecallRequest } from "@one-memory/contracts";
|
||||
import {
|
||||
diagnosticTraceThroughBroker,
|
||||
isMemoryNoteRequestId,
|
||||
isMemoryNoteText,
|
||||
MEMORY_NOTE_TEXT_MAX,
|
||||
type MemoryNoteRequest,
|
||||
memoryNoteThroughBroker,
|
||||
isMemoryAddRequestId,
|
||||
isMemoryAddText,
|
||||
MEMORY_ADD_TEXT_MAX,
|
||||
type MemoryAddBrokerRequest,
|
||||
memoryAddThroughBroker,
|
||||
recallThroughBroker,
|
||||
} from "./broker-client.js";
|
||||
import { HOST_CONTEXT_TIMEOUT_MS } from "./context-protocol.js";
|
||||
@@ -37,8 +37,8 @@ type Recall = (
|
||||
|
||||
type Write = (
|
||||
discoveryPath: string,
|
||||
request: MemoryNoteRequest,
|
||||
) => Promise<MemoryNoteReceipt>;
|
||||
request: MemoryAddBrokerRequest,
|
||||
) => Promise<MemoryAddReceipt>;
|
||||
|
||||
type DiagnosticTrace = (
|
||||
discoveryPath: string,
|
||||
@@ -77,9 +77,9 @@ export class McpSession {
|
||||
* which is exactly the cross-project boundary the workspace design
|
||||
* draws. The host's own answer cannot be talked into being wrong. */
|
||||
private readonly workspacePath?: string,
|
||||
/** One hand-added memory, through the broker to Core's memory notes —
|
||||
/** One hand-added memory, through the broker to Core's `POST /api/v2/memories` —
|
||||
* the same entry the app's 添加记忆 uses. */
|
||||
private readonly write: Write = memoryNoteThroughBroker,
|
||||
private readonly write: Write = memoryAddThroughBroker,
|
||||
private readonly diagnosticTrace: DiagnosticTrace = diagnosticTraceThroughBroker,
|
||||
/** What the grant allows this installation to read.
|
||||
*
|
||||
@@ -295,9 +295,9 @@ function recallToolDefinition() {
|
||||
|
||||
/** One hand-added memory, the same entry as the app's 添加记忆.
|
||||
*
|
||||
* A conclusion, not a transcript: Core files it as a manual note and turns it
|
||||
* A conclusion, not a transcript: Core files it as a hand-added memory and turns it
|
||||
* into memories asynchronously, in a lane ahead of history import. Neither
|
||||
* source nor scope is an argument; the note is always cross-app. */
|
||||
* source nor scope is an argument; the memory is always cross-app. */
|
||||
function writeToolDefinition() {
|
||||
const title = "MemHub write";
|
||||
return {
|
||||
@@ -319,7 +319,7 @@ function writeToolDefinition() {
|
||||
text: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: MEMORY_NOTE_TEXT_MAX,
|
||||
maxLength: MEMORY_ADD_TEXT_MAX,
|
||||
description: "The memory itself, in plain words. Must not be blank.",
|
||||
},
|
||||
request_id: {
|
||||
@@ -334,7 +334,7 @@ function writeToolDefinition() {
|
||||
title,
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
// Idempotent for a reused request_id: Core returns the original note.
|
||||
// Idempotent for a reused request_id: Core returns the original addition.
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
@@ -451,15 +451,12 @@ function parseWriteArgs(args: unknown):
|
||||
message:
|
||||
"MemHub_write accepts only text and request_id; source and scope are not arguments",
|
||||
};
|
||||
if (!isMemoryNoteText(input.text))
|
||||
if (!isMemoryAddText(input.text))
|
||||
return {
|
||||
ok: false,
|
||||
message: `text must contain 1 to ${MEMORY_NOTE_TEXT_MAX} characters and not be blank`,
|
||||
message: `text must contain 1 to ${MEMORY_ADD_TEXT_MAX} characters and not be blank`,
|
||||
};
|
||||
if (
|
||||
input.request_id !== undefined &&
|
||||
!isMemoryNoteRequestId(input.request_id)
|
||||
)
|
||||
if (input.request_id !== undefined && !isMemoryAddRequestId(input.request_id))
|
||||
return { ok: false, message: "request_id must be a UUID" };
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type BrokerClientOptions,
|
||||
diagnosticTraceThroughBroker,
|
||||
grantedToolsThroughBroker,
|
||||
memoryNoteThroughBroker,
|
||||
memoryAddThroughBroker,
|
||||
readHostCredentialFile,
|
||||
recallThroughBroker,
|
||||
} from "./broker-client.js";
|
||||
@@ -35,7 +35,7 @@ const session = new McpSession(
|
||||
}),
|
||||
// The broker refuses a write without an authenticated installation.
|
||||
(discovery, request) =>
|
||||
memoryNoteThroughBroker(discovery, request, clientOptions),
|
||||
memoryAddThroughBroker(discovery, request, clientOptions),
|
||||
(discovery, request) =>
|
||||
diagnosticTraceThroughBroker(discovery, request, clientOptions),
|
||||
// What the grant allows, so the list a host sees is the list it may use.
|
||||
|
||||
+18
-18
@@ -1,4 +1,4 @@
|
||||
// MemHub_write end to end: MCP session -> broker -> a fake Core. The note is
|
||||
// MemHub_write end to end: MCP session -> broker -> a fake Core. The memory is
|
||||
// the same hand-added memory the app's 添加记忆 writes: authenticated by the
|
||||
// host credential, not grant-governed, sent to Core as a trusted local caller
|
||||
// (no external read envelope) and always filed cross-app.
|
||||
@@ -13,7 +13,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type BrokerClientOptions,
|
||||
memoryNoteThroughBroker,
|
||||
memoryAddThroughBroker,
|
||||
readHostCredentialFile,
|
||||
} from "./broker-client.js";
|
||||
import { HostGrantStore } from "./host-grants.js";
|
||||
@@ -54,8 +54,8 @@ function receiptFor(text: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(options: { noteStatus?: number } = {}) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "om-note-broker-"));
|
||||
async function fixture(options: { coreStatus?: number } = {}) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "om-memory-add-broker-"));
|
||||
roots.push(root);
|
||||
const store = await HostGrantStore.initialize(path.join(root, "grants"));
|
||||
const issued = await store.createInstallation({ hostId: "cursor" });
|
||||
@@ -82,8 +82,8 @@ async function fixture(options: { noteStatus?: number } = {}) {
|
||||
body,
|
||||
});
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
if (request.url === "/api/v1/memory-notes") {
|
||||
response.statusCode = options.noteStatus ?? 201;
|
||||
if (request.url === "/api/v2/memories") {
|
||||
response.statusCode = options.coreStatus ?? 201;
|
||||
response.end(
|
||||
JSON.stringify(
|
||||
response.statusCode === 201
|
||||
@@ -107,7 +107,7 @@ async function fixture(options: { noteStatus?: number } = {}) {
|
||||
coreBaseUrl: `http://127.0.0.1:${(core.address() as AddressInfo).port}`,
|
||||
coreApiToken: coreToken,
|
||||
discoveryPath,
|
||||
instanceId: "isolated-note-broker",
|
||||
instanceId: "isolated-memory-add-broker",
|
||||
preferredPort: 0,
|
||||
hostGrantStore: store,
|
||||
});
|
||||
@@ -120,7 +120,7 @@ async function fixture(options: { noteStatus?: number } = {}) {
|
||||
discoveryPath,
|
||||
undefined,
|
||||
undefined,
|
||||
(discovery, request) => memoryNoteThroughBroker(discovery, request, client),
|
||||
(discovery, request) => memoryAddThroughBroker(discovery, request, client),
|
||||
undefined,
|
||||
async () => [],
|
||||
);
|
||||
@@ -168,14 +168,14 @@ describe.skipIf(process.platform === "win32")("MemHub_write", () => {
|
||||
expect(f.calls).toHaveLength(1);
|
||||
const [call] = f.calls;
|
||||
expect(call.method).toBe("POST");
|
||||
expect(call.url).toBe("/api/v1/memory-notes");
|
||||
expect(call.url).toBe("/api/v2/memories");
|
||||
expect(call.body).toEqual({
|
||||
request_id: REQUEST_ID,
|
||||
text: "Prefers tabs over spaces",
|
||||
scope: "all-apps",
|
||||
});
|
||||
expect(call.headers.authorization).toBe(`Bearer ${coreToken}`);
|
||||
// Core denies memory notes to enveloped requests: none of it may leak.
|
||||
// Core denies POST /api/v2/memories to enveloped requests: none of it may leak.
|
||||
for (const header of Object.keys(call.headers)) {
|
||||
expect(header).not.toMatch(
|
||||
/^x-one-memory-(read-context|host-id|installation-|allowed-)/,
|
||||
@@ -194,34 +194,34 @@ describe.skipIf(process.platform === "win32")("MemHub_write", () => {
|
||||
});
|
||||
|
||||
it("maps a 409 conflict and a 422 refusal to their own errors", async () => {
|
||||
const conflict = await fixture({ noteStatus: 409 });
|
||||
const conflict = await fixture({ coreStatus: 409 });
|
||||
await expect(
|
||||
memoryNoteThroughBroker(
|
||||
memoryAddThroughBroker(
|
||||
conflict.discoveryPath,
|
||||
{ request_id: REQUEST_ID, text: "a" },
|
||||
conflict.client,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "memory_note_conflict" });
|
||||
).rejects.toMatchObject({ code: "memory_add_conflict" });
|
||||
const conflictCall = await callWrite(conflict.session, {
|
||||
text: "a",
|
||||
request_id: REQUEST_ID,
|
||||
});
|
||||
expect(conflictCall.result?.isError).toBe(true);
|
||||
|
||||
const invalid = await fixture({ noteStatus: 422 });
|
||||
const invalid = await fixture({ coreStatus: 422 });
|
||||
await expect(
|
||||
memoryNoteThroughBroker(
|
||||
memoryAddThroughBroker(
|
||||
invalid.discoveryPath,
|
||||
{ request_id: REQUEST_ID, text: "a" },
|
||||
invalid.client,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "memory_note_invalid" });
|
||||
).rejects.toMatchObject({ code: "memory_add_invalid" });
|
||||
});
|
||||
|
||||
it("refuses an unauthenticated write before reaching Core", async () => {
|
||||
const f = await fixture();
|
||||
const response = await axios.post(
|
||||
`${f.baseUrl}/memory-notes`,
|
||||
`${f.baseUrl}/memories`,
|
||||
{ request_id: REQUEST_ID, text: "anything" },
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -243,7 +243,7 @@ describe.skipIf(process.platform === "win32")("MemHub_write", () => {
|
||||
{ request_id: REQUEST_ID, text: "x".repeat(12_001) },
|
||||
{ text: "x" },
|
||||
]) {
|
||||
const response = await axios.post(`${f.baseUrl}/memory-notes`, body, {
|
||||
const response = await axios.post(`${f.baseUrl}/memories`, body, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${f.secret}`,
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
import axios from "axios";
|
||||
import {
|
||||
type CallEventDeliveryRequest,
|
||||
isMemoryNoteRequest,
|
||||
isMemoryAddBrokerRequest,
|
||||
} from "./broker-client.js";
|
||||
import {
|
||||
authorizeContext,
|
||||
@@ -41,7 +41,7 @@ const LOOPBACK_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 18_765;
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
/** 12,000 characters, each of which JSON may escape to six bytes. */
|
||||
const MAX_MEMORY_NOTE_BODY_BYTES = 128 * 1024;
|
||||
const MAX_MEMORY_ADD_BODY_BYTES = 128 * 1024;
|
||||
const CONNECTION_DIAGNOSTIC_SUMMARY_MS = 5 * 60 * 1000;
|
||||
const DELIVERY_BINDING_TTL_MS = 60_000;
|
||||
const CALL_EVENT_ID =
|
||||
@@ -319,8 +319,8 @@ export class RecallBroker {
|
||||
await this.proxyContext(request, response);
|
||||
return;
|
||||
}
|
||||
if (request.url === "/memory-notes") {
|
||||
await this.proxyMemoryNote(request, response);
|
||||
if (request.url === "/memories") {
|
||||
await this.proxyMemoryAdd(request, response);
|
||||
return;
|
||||
}
|
||||
if (request.url === "/diagnostics/trace") {
|
||||
@@ -976,7 +976,7 @@ export class RecallBroker {
|
||||
|
||||
/** The installation behind a request: authenticated, never tool-authorized.
|
||||
*
|
||||
* Memory notes are not grant-governed, but only an installation MemHub
|
||||
* Hand-added memories are not grant-governed, but only an installation MemHub
|
||||
* paired may write one. Without a grant store there is no identity, so no
|
||||
* write. */
|
||||
private async authenticateHost(
|
||||
@@ -1005,9 +1005,9 @@ export class RecallBroker {
|
||||
}
|
||||
}
|
||||
|
||||
/** Core's memory-note entry, as a trusted local caller.
|
||||
/** Core's memory-add entry, as a trusted local caller.
|
||||
*
|
||||
* Core refuses `/api/v1/memory-notes` to requests that carry the external
|
||||
* Core refuses `POST /api/v2/memories` to requests that carry the external
|
||||
* read envelope, and writes are not grant-governed. */
|
||||
private coreWriteHeaders(request: IncomingMessage): Record<string, string> {
|
||||
return {
|
||||
@@ -1020,8 +1020,8 @@ export class RecallBroker {
|
||||
/** One hand-added memory, the same entry as the app's 添加记忆.
|
||||
*
|
||||
* The body is exactly `{request_id, text}`: a host cannot choose a scope or
|
||||
* a source. The note is filed cross-app, as a hand-added memory. */
|
||||
private async proxyMemoryNote(
|
||||
* a source. The memory is filed cross-app, as a hand-added memory. */
|
||||
private async proxyMemoryAdd(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
): Promise<void> {
|
||||
@@ -1030,14 +1030,14 @@ export class RecallBroker {
|
||||
const body = await readJsonBody(
|
||||
request,
|
||||
undefined,
|
||||
MAX_MEMORY_NOTE_BODY_BYTES,
|
||||
MAX_MEMORY_ADD_BODY_BYTES,
|
||||
);
|
||||
if (!isMemoryNoteRequest(body)) {
|
||||
sendJson(response, 400, { error: "memory_note_invalid" });
|
||||
if (!isMemoryAddBrokerRequest(body)) {
|
||||
sendJson(response, 400, { error: "memory_add_invalid" });
|
||||
return;
|
||||
}
|
||||
const result = await axios.post<unknown>(
|
||||
`${this.options.coreBaseUrl}/api/v1/memory-notes`,
|
||||
`${this.options.coreBaseUrl}/api/v2/memories`,
|
||||
{ request_id: body.request_id, text: body.text, scope: "all-apps" },
|
||||
{
|
||||
headers: this.coreWriteHeaders(request),
|
||||
@@ -1052,11 +1052,11 @@ export class RecallBroker {
|
||||
return;
|
||||
}
|
||||
if (result.status === 409) {
|
||||
sendJson(response, 409, { error: "memory_note_conflict" });
|
||||
sendJson(response, 409, { error: "memory_add_conflict" });
|
||||
return;
|
||||
}
|
||||
if (result.status === 422) {
|
||||
sendJson(response, 422, { error: "memory_note_invalid" });
|
||||
sendJson(response, 422, { error: "memory_add_invalid" });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 502, { error: "MemHub memory write is unavailable" });
|
||||
@@ -1246,7 +1246,7 @@ function brokerOperation(url: string | undefined): string | null {
|
||||
"/recall": "recall",
|
||||
"/context": "context",
|
||||
"/call-events/delivery": "call_event_delivery",
|
||||
"/memory-notes": "memory_note",
|
||||
"/memories": "memory_add",
|
||||
"/source-changed": "source_notification",
|
||||
"/control/connections": "connection_control",
|
||||
}[url ?? ""] ?? null
|
||||
|
||||
@@ -1730,17 +1730,13 @@ const baseEnglishMessages = {
|
||||
"memory:整理已完成": "Processing complete",
|
||||
"memory:原话已保存,未提取出可用记忆":
|
||||
"Original saved; no usable memory extracted",
|
||||
"memory:最近添加的内容读取失败。": "Could not read recently added notes.",
|
||||
"memory:最近添加的内容读取失败。": "Could not read recently added memories.",
|
||||
"memory:整理状态暂时不可用,原话已保存。":
|
||||
"Processing status is temporarily unavailable; the original is saved.",
|
||||
"memory:已保存,正在后台沉淀记忆":
|
||||
"Saved; creating memories in the background",
|
||||
"memory:未能确认保存结果,输入已保留。重试不会重复添加。":
|
||||
"Could not confirm the save result. Your text is retained; retrying will not add a duplicate.",
|
||||
"memory:整理未能开始,原话仍已保存。":
|
||||
"Processing could not start; the original is still saved.",
|
||||
"memory:整理结果尚未确认,原话仍已保存。请刷新后核对。":
|
||||
"Processing is not yet confirmed; the original is saved. Refresh to check.",
|
||||
"memory:模型服务要求检查余额或付款状态,原话仍已保存。":
|
||||
"The model service requires a balance or payment check; the original remains saved.",
|
||||
"memory:这台设备打不开 3D 视图,已显示关联列表。":
|
||||
|
||||
@@ -300,9 +300,6 @@ export const memoryChineseMessages = {
|
||||
"memory:已保存,正在后台沉淀记忆": "已保存,正在后台沉淀记忆",
|
||||
"memory:未能确认保存结果,输入已保留。重试不会重复添加。":
|
||||
"未能确认保存结果,输入已保留。重试不会重复添加。",
|
||||
"memory:整理未能开始,原话仍已保存。": "整理未能开始,原话仍已保存。",
|
||||
"memory:整理结果尚未确认,原话仍已保存。请刷新后核对。":
|
||||
"整理结果尚未确认,原话仍已保存。请刷新后核对。",
|
||||
"memory:模型服务要求检查余额或付款状态,原话仍已保存。":
|
||||
"模型服务要求检查余额或付款状态,原话仍已保存。",
|
||||
"memory:这台设备打不开 3D 视图,已显示关联列表。":
|
||||
@@ -707,17 +704,14 @@ export const memorySpanishMessages: Record<
|
||||
"memory:整理已完成": "Procesamiento completado",
|
||||
"memory:原话已保存,未提取出可用记忆":
|
||||
"Texto guardado; no se extrajeron memorias utilizables",
|
||||
"memory:最近添加的内容读取失败。": "No se pudieron leer las notas recientes.",
|
||||
"memory:最近添加的内容读取失败。":
|
||||
"No se pudieron leer las memorias añadidas recientemente.",
|
||||
"memory:整理状态暂时不可用,原话已保存。":
|
||||
"Estado del procesamiento no disponible. El texto está guardado.",
|
||||
"memory:已保存,正在后台沉淀记忆":
|
||||
"Guardado; generando memorias en segundo plano",
|
||||
"memory:未能确认保存结果,输入已保留。重试不会重复添加。":
|
||||
"No se pudo confirmar el guardado. Tu texto se conserva; reintentar no lo duplicará.",
|
||||
"memory:整理未能开始,原话仍已保存。":
|
||||
"No se pudo iniciar el procesamiento. El texto sigue guardado.",
|
||||
"memory:整理结果尚未确认,原话仍已保存。请刷新后核对。":
|
||||
"El resultado no está confirmado. El texto sigue guardado; actualiza para verificarlo.",
|
||||
"memory:模型服务要求检查余额或付款状态,原话仍已保存。":
|
||||
"Revisa el saldo o los pagos del proveedor. El texto sigue guardado.",
|
||||
"memory:这台设备打不开 3D 视图,已显示关联列表。":
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* copies drifting apart.
|
||||
*/
|
||||
|
||||
export * from "./memory-note-operation.js";
|
||||
export * from "./memory-add-operation.js";
|
||||
export * from "./memory-processing-operation.js";
|
||||
export * from "./model-operation.js";
|
||||
export * from "./model-profile-operation.js";
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildMemoryAddRequest,
|
||||
MemoryAddInputError,
|
||||
memoryAddStatusPath,
|
||||
parseMemoryAddReceipt,
|
||||
} from "./memory-add-operation";
|
||||
|
||||
const ID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
describe("memory add request", () => {
|
||||
it("sends exactly request_id, text and the fixed scope", () => {
|
||||
expect(
|
||||
buildMemoryAddRequest({
|
||||
request_id: ID,
|
||||
text: "raw memory",
|
||||
scope: "all-apps",
|
||||
}),
|
||||
).toEqual({
|
||||
path: "/api/v2/memories",
|
||||
body: { request_id: ID, text: "raw memory", scope: "all-apps" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a bad request id or empty text", () => {
|
||||
expect(() =>
|
||||
buildMemoryAddRequest({
|
||||
request_id: "nope",
|
||||
text: "x",
|
||||
scope: "all-apps",
|
||||
}),
|
||||
).toThrow("request_id_invalid");
|
||||
expect(() =>
|
||||
buildMemoryAddRequest({
|
||||
request_id: ID,
|
||||
text: " ",
|
||||
scope: "all-apps",
|
||||
}),
|
||||
).toThrow(MemoryAddInputError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory add status path", () => {
|
||||
it("addresses the addition by its request id", () => {
|
||||
expect(memoryAddStatusPath(ID)).toBe(`/api/v2/memories/additions/${ID}`);
|
||||
});
|
||||
|
||||
it("refuses a non-UUID request id", () => {
|
||||
for (const value of ["nope", "", `${ID}/../x`]) {
|
||||
expect(() => memoryAddStatusPath(value)).toThrow(MemoryAddInputError);
|
||||
expect(() => memoryAddStatusPath(value)).toThrow("request_id_invalid");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory add receipt validation", () => {
|
||||
function receipt(patch: Record<string, unknown> = {}) {
|
||||
return {
|
||||
evidence_id: ID,
|
||||
text: "raw memory",
|
||||
scope: "all-apps",
|
||||
created_at: "2026-09-13T00:00:00+00:00",
|
||||
state: "pending",
|
||||
task_id: null,
|
||||
memory_ids: [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
it("accepts a well-formed receipt", () => {
|
||||
expect(parseMemoryAddReceipt(receipt())?.state).toBe("pending");
|
||||
});
|
||||
|
||||
it("refuses a non-UUID evidence id or an unknown state", () => {
|
||||
expect(parseMemoryAddReceipt(receipt({ evidence_id: "nope" }))).toBeNull();
|
||||
expect(parseMemoryAddReceipt(receipt({ state: "invented" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a wrong scope or malformed memory_ids", () => {
|
||||
expect(parseMemoryAddReceipt(receipt({ scope: "private" }))).toBeNull();
|
||||
expect(parseMemoryAddReceipt(receipt({ memory_ids: [1] }))).toBeNull();
|
||||
expect(
|
||||
parseMemoryAddReceipt(receipt({ memory_ids: undefined })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a non-object body", () => {
|
||||
for (const value of [null, [], "ok", 7]) {
|
||||
expect(parseMemoryAddReceipt(value)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/** Memory-add contract: the `POST /api/v2/memories` body, its receipt check and
|
||||
* the addition status path.
|
||||
*
|
||||
* Core takes exactly `request_id`, `text`, `scope`; the CLI and the UI both
|
||||
* hard-code `scope: "all-apps"`, so keeping the body here means both surfaces
|
||||
* send the same three fields. A receipt is validated before it becomes state —
|
||||
* the evidence id must be a UUID and the state must be one of the known
|
||||
* states — so a malformed 200 is never rendered as a real addition.
|
||||
*
|
||||
* Dependency-free (contracts types only) so it can move into a shared package.
|
||||
*/
|
||||
import type { MemoryAddInput, MemoryAddReceipt } from "@one-memory/contracts";
|
||||
|
||||
export class MemoryAddInputError extends Error {}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const ADD_STATES = [
|
||||
"waiting_for_model",
|
||||
"waiting_for_permission",
|
||||
"pending",
|
||||
"running",
|
||||
"failed",
|
||||
"ready",
|
||||
"no_memories",
|
||||
] as const;
|
||||
|
||||
export interface MemoryAddRequest {
|
||||
path: "/api/v2/memories";
|
||||
body: { request_id: string; text: string; scope: "all-apps" };
|
||||
}
|
||||
|
||||
/** The exact three-field body Core accepts; scope is fixed, not caller-supplied. */
|
||||
export function buildMemoryAddRequest(input: MemoryAddInput): MemoryAddRequest {
|
||||
if (typeof input.request_id !== "string" || !UUID.test(input.request_id))
|
||||
throw new MemoryAddInputError("request_id_invalid");
|
||||
if (typeof input.text !== "string" || input.text.trim().length === 0)
|
||||
throw new MemoryAddInputError("text_required");
|
||||
return {
|
||||
path: "/api/v2/memories",
|
||||
body: { request_id: input.request_id, text: input.text, scope: "all-apps" },
|
||||
};
|
||||
}
|
||||
|
||||
/** Where the status of one addition is read, keyed by its request id. */
|
||||
export function memoryAddStatusPath(requestId: string): string {
|
||||
if (typeof requestId !== "string" || !UUID.test(requestId))
|
||||
throw new MemoryAddInputError("request_id_invalid");
|
||||
return `/api/v2/memories/additions/${requestId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a memory-add receipt before it becomes UI state.
|
||||
*
|
||||
* Returns null for a malformed body; callers must treat that as an unknown
|
||||
* result rather than a saved memory.
|
||||
*/
|
||||
export function parseMemoryAddReceipt(value: unknown): MemoryAddReceipt | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const receipt = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof receipt.evidence_id !== "string" ||
|
||||
!UUID.test(receipt.evidence_id) ||
|
||||
typeof receipt.state !== "string" ||
|
||||
!(ADD_STATES as readonly string[]).includes(receipt.state) ||
|
||||
receipt.scope !== "all-apps"
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
!Array.isArray(receipt.memory_ids) ||
|
||||
receipt.memory_ids.some((id) => typeof id !== "string")
|
||||
)
|
||||
return null;
|
||||
return value as MemoryAddReceipt;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildMemoryNoteSaveRequest,
|
||||
MemoryNoteInputError,
|
||||
parseMemoryNoteReceipt,
|
||||
} from "./memory-note-operation";
|
||||
|
||||
const ID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
describe("memory note save request", () => {
|
||||
it("sends exactly request_id, text and the fixed scope", () => {
|
||||
expect(
|
||||
buildMemoryNoteSaveRequest({
|
||||
request_id: ID,
|
||||
text: "raw note",
|
||||
scope: "all-apps",
|
||||
}),
|
||||
).toEqual({
|
||||
path: "/api/v1/memory-notes",
|
||||
body: { request_id: ID, text: "raw note", scope: "all-apps" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a bad request id or empty text", () => {
|
||||
expect(() =>
|
||||
buildMemoryNoteSaveRequest({
|
||||
request_id: "nope",
|
||||
text: "x",
|
||||
scope: "all-apps",
|
||||
}),
|
||||
).toThrow("request_id_invalid");
|
||||
expect(() =>
|
||||
buildMemoryNoteSaveRequest({
|
||||
request_id: ID,
|
||||
text: " ",
|
||||
scope: "all-apps",
|
||||
}),
|
||||
).toThrow(MemoryNoteInputError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory note receipt validation", () => {
|
||||
function receipt(patch: Record<string, unknown> = {}) {
|
||||
return {
|
||||
evidence_id: ID,
|
||||
text: "raw note",
|
||||
scope: "all-apps",
|
||||
created_at: "2026-09-13T00:00:00+00:00",
|
||||
state: "pending",
|
||||
task_id: null,
|
||||
memory_ids: [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
it("accepts a well-formed receipt", () => {
|
||||
expect(parseMemoryNoteReceipt(receipt())?.state).toBe("pending");
|
||||
});
|
||||
|
||||
it("refuses a non-UUID evidence id or an unknown state", () => {
|
||||
expect(parseMemoryNoteReceipt(receipt({ evidence_id: "nope" }))).toBeNull();
|
||||
expect(parseMemoryNoteReceipt(receipt({ state: "invented" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a wrong scope or malformed memory_ids", () => {
|
||||
expect(parseMemoryNoteReceipt(receipt({ scope: "private" }))).toBeNull();
|
||||
expect(parseMemoryNoteReceipt(receipt({ memory_ids: [1] }))).toBeNull();
|
||||
expect(
|
||||
parseMemoryNoteReceipt(receipt({ memory_ids: undefined })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a non-object body", () => {
|
||||
for (const value of [null, [], "ok", 7]) {
|
||||
expect(parseMemoryNoteReceipt(value)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
/** Memory-note write contract: the body shape and the receipt check.
|
||||
*
|
||||
* The CLI validates a note receipt in `ui-write-flows-extended.ts:321-340`
|
||||
* before accepting it — the evidence id must be a UUID and the state must be
|
||||
* one of the known states. The renderer previously stored whatever Core
|
||||
* returned straight into component state (`MemoryNoteComposer.remember`), so a
|
||||
* malformed 200 would be rendered as a real note. This mirrors the CLI's check.
|
||||
*
|
||||
* The save body is fixed too: Core takes exactly `request_id`, `text`, `scope`;
|
||||
* the CLI hard-codes `scope: "all-apps"` and so does the UI. Keeping it here
|
||||
* means both surfaces send the same three fields.
|
||||
*
|
||||
* Dependency-free (contracts types only) so it can move into a shared package.
|
||||
*/
|
||||
import type { MemoryNoteInput, MemoryNoteReceipt } from "@one-memory/contracts";
|
||||
|
||||
export class MemoryNoteInputError extends Error {}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const NOTE_STATES = [
|
||||
"waiting_for_model",
|
||||
"waiting_for_permission",
|
||||
"pending",
|
||||
"running",
|
||||
"failed",
|
||||
"ready",
|
||||
"no_memories",
|
||||
] as const;
|
||||
|
||||
export interface MemoryNoteSaveRequest {
|
||||
path: "/api/v1/memory-notes";
|
||||
body: { request_id: string; text: string; scope: "all-apps" };
|
||||
}
|
||||
|
||||
/** The exact three-field body Core accepts; scope is fixed, not caller-supplied. */
|
||||
export function buildMemoryNoteSaveRequest(
|
||||
note: MemoryNoteInput,
|
||||
): MemoryNoteSaveRequest {
|
||||
if (typeof note.request_id !== "string" || !UUID.test(note.request_id))
|
||||
throw new MemoryNoteInputError("request_id_invalid");
|
||||
if (typeof note.text !== "string" || note.text.trim().length === 0)
|
||||
throw new MemoryNoteInputError("text_required");
|
||||
return {
|
||||
path: "/api/v1/memory-notes",
|
||||
body: { request_id: note.request_id, text: note.text, scope: "all-apps" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a note receipt before it becomes UI state.
|
||||
*
|
||||
* Returns null for a malformed body; callers must treat that as an unknown
|
||||
* result rather than a saved note.
|
||||
*/
|
||||
export function parseMemoryNoteReceipt(
|
||||
value: unknown,
|
||||
): MemoryNoteReceipt | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const receipt = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof receipt.evidence_id !== "string" ||
|
||||
!UUID.test(receipt.evidence_id) ||
|
||||
typeof receipt.state !== "string" ||
|
||||
!(NOTE_STATES as readonly string[]).includes(receipt.state) ||
|
||||
receipt.scope !== "all-apps"
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
!Array.isArray(receipt.memory_ids) ||
|
||||
receipt.memory_ids.some((id) => typeof id !== "string")
|
||||
)
|
||||
return null;
|
||||
return value as MemoryNoteReceipt;
|
||||
}
|
||||
@@ -16,9 +16,8 @@ describe("extraction window receipt", () => {
|
||||
).toEqual({ window_preset: "6m" });
|
||||
});
|
||||
|
||||
// The CLI rejects a receipt that does not match the plan
|
||||
// (`ui-write-flows-extended.ts:executeExtractionWindowSet`); a receipt for a
|
||||
// different preset means the response belongs to another write.
|
||||
// A receipt for a different preset than the one requested means the
|
||||
// response belongs to another write.
|
||||
it("rejects a preset that is not the one requested", () => {
|
||||
expect(
|
||||
parseExtractionSettingsReceipt({ window_preset: "all" }, "6m"),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/** Memory-processing writes: extraction window, graph width, manual extract.
|
||||
*
|
||||
* These three are the same class of gap as the model-profile and
|
||||
* continuous-policy receipts: the CLI refuses to trust a 2xx body
|
||||
* (`ui-write-flows-extended.ts` `executeExtractionWindowSet` /
|
||||
* `executeGraphWidthDecide` / `executeManualExtract`), reporting
|
||||
* `ui_write_receipt_invalid` instead of a committed write. The Web returned
|
||||
* continuous-policy receipts: a 2xx body is not trusted until it parses
|
||||
* (the CLI's `graph-width-flow.ts` reports `graph_width_receipt_invalid`
|
||||
* instead of a committed write). The Web returned
|
||||
* `response.data` straight into component state.
|
||||
*
|
||||
* The graph-width case is the one that can lie: `GraphWidthPrompt` treats an
|
||||
|
||||
@@ -176,6 +176,7 @@ export {
|
||||
} from "./runtime-supervisor.js";
|
||||
export {
|
||||
type CoreCommand,
|
||||
readInstalledRuntimeSource,
|
||||
runSdkMigration,
|
||||
type SdkMigrationCommandResult,
|
||||
type SdkMigrationOptions,
|
||||
@@ -183,13 +184,18 @@ export {
|
||||
} from "./sdk-migration.js";
|
||||
export {
|
||||
clearSdkMigrationBlock,
|
||||
freezeCapture,
|
||||
readSdkMigrationBlock,
|
||||
recordSdkMigrationBlock,
|
||||
SDK_MIGRATION_CAPTURE_FENCE,
|
||||
SDK_MIGRATION_LOG_NAME,
|
||||
type SdkMigrationBlock,
|
||||
type SdkMigrationBlockDetails,
|
||||
type SdkMigrationCaller,
|
||||
type SdkMigrationLogEntry,
|
||||
type SdkMigrationSource,
|
||||
sdkMigrationLogPath,
|
||||
thawCapture,
|
||||
writeSdkMigrationLog,
|
||||
} from "./sdk-migration-log.js";
|
||||
export {
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user