Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
748588dc6e |
No files matched your search
@@ -59,4 +59,30 @@ jobs:
|
||||
- run: corepack enable
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: uv sync --directory apps/core --all-groups --locked
|
||||
- run: pnpm check
|
||||
- name: Verify public source structure and privacy boundaries
|
||||
run: |
|
||||
python3 scripts/verify_structure.py
|
||||
python3 scripts/generate_core_test_index.py --check
|
||||
python3 scripts/verify_zero_collection.py
|
||||
python3 scripts/verify_zero_collection.py --self-test
|
||||
- name: Lint Node workspaces
|
||||
run: pnpm lint
|
||||
- name: Typecheck Node workspaces
|
||||
run: pnpm typecheck
|
||||
- name: Test Node workspaces
|
||||
run: pnpm test
|
||||
- name: Test release tooling
|
||||
run: pnpm test:release-scripts
|
||||
- name: Fetch and verify the pinned embedding model
|
||||
run: |
|
||||
pnpm fetch:embedding-model
|
||||
uv run --directory apps/core python -c \
|
||||
"from one_memory.embedding_assets import assets_present, verify_assets; assert assets_present(); verify_assets()"
|
||||
- name: Test public Core smoke suite
|
||||
run: >-
|
||||
bash scripts/run-tests-isolated.sh core-pr
|
||||
tests/test_core_test_reporting.py
|
||||
tests/test_import_api.py
|
||||
tests/test_recent_first_import.py
|
||||
tests/test_source_session_count_api.py
|
||||
tests/test_schema_migrations.py
|
||||
@@ -13,8 +13,8 @@
|
||||
* the real package.json and only strips what is provably not needed at runtime.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -27,10 +27,7 @@ const releaseVersion = resolveBuildVersion(process.env, repositoryRoot);
|
||||
const pkgPath = resolve(root, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
const publishName = process.env.MEMHUB_NPM_PACKAGE?.trim();
|
||||
if (
|
||||
!publishName ||
|
||||
!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)
|
||||
) {
|
||||
if (!publishName || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)) {
|
||||
throw new Error(
|
||||
"MEMHUB_NPM_PACKAGE must name the explicit npm publish identity",
|
||||
);
|
||||
@@ -38,8 +35,12 @@ if (
|
||||
|
||||
const bundle = resolve(root, "dist/one-memory.cjs");
|
||||
const source = readFileSync(bundle, "utf8");
|
||||
const runtimeRequires = [...source.matchAll(/require\(["']([^"']+)["']\)/g)].map((m) => m[1]);
|
||||
const workspaceLeaked = runtimeRequires.filter((name) => name.startsWith("@one-memory/"));
|
||||
const runtimeRequires = [
|
||||
...source.matchAll(/require\(["']([^"']+)["']\)/g),
|
||||
].map((m) => m[1]);
|
||||
const workspaceLeaked = runtimeRequires.filter((name) =>
|
||||
name.startsWith("@one-memory/"),
|
||||
);
|
||||
if (workspaceLeaked.length > 0) {
|
||||
throw new Error(
|
||||
`bundle still requires workspace packages at runtime: ${workspaceLeaked.join(", ")}. ` +
|
||||
@@ -61,9 +62,7 @@ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
// else is build-time only. The bundle is inlined (tsup noExternal), so
|
||||
// in practice only Node builtins remain — but compute it rather than
|
||||
// assume, so a future non-inlined dep is not silently dropped.
|
||||
runtimeRequires.some(
|
||||
(req) => req === name || req.startsWith(`${name}/`),
|
||||
),
|
||||
runtimeRequires.some((req) => req === name || req.startsWith(`${name}/`)),
|
||||
),
|
||||
);
|
||||
if (Object.keys(published[section]).length === 0) delete published[section];
|
||||
@@ -77,8 +76,15 @@ published.files = ["dist", "scripts", "package.json"];
|
||||
// in the staging dir overrides that and makes the manifest authoritative.
|
||||
writeFileSync(
|
||||
resolve(outDir, ".npmignore"),
|
||||
["# Ship the built bundle: the repo root .gitignore excludes dist/.", "!dist/", ""].join("\n"),
|
||||
[
|
||||
"# Ship the built bundle: the repo root .gitignore excludes dist/.",
|
||||
"!dist/",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
writeFileSync(resolve(outDir, "package.json"), `${JSON.stringify(published, null, 2)}\n`);
|
||||
writeFileSync(
|
||||
resolve(outDir, "package.json"),
|
||||
`${JSON.stringify(published, null, 2)}\n`,
|
||||
);
|
||||
console.log("wrote", resolve(outDir, "package.json"));
|
||||
@@ -20,11 +20,7 @@ import { pathToFileURL } from "node:url";
|
||||
|
||||
const REQUIRED_ALIASES = {
|
||||
daemon: ["start", "stop", "restart", "status"],
|
||||
"startup-service": [
|
||||
"service-plan",
|
||||
"service-install",
|
||||
"service-uninstall",
|
||||
],
|
||||
"startup-service": ["service-plan", "service-install", "service-uninstall"],
|
||||
};
|
||||
|
||||
const REQUIRED_COMMAND_SURFACE = [
|
||||
@@ -112,19 +108,85 @@ const REQUIRED_SUBCOMMANDS = [
|
||||
* These run without `--lifecycle`, so they are safe against any package.
|
||||
*/
|
||||
const STOPPED_DESTRUCTIVE_PROBES = [
|
||||
{ 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: "import_file_commit_apply", args: ["import-file", "commit", "22222222-2222-4222-8222-222222222222", "--confirm"] },
|
||||
{ name: "import_file_discard_apply", args: ["import-file", "discard", "22222222-2222-4222-8222-222222222222", "--confirm"] },
|
||||
{ name: "notes_process_apply", args: ["notes", "process", "33333333-3333-4333-8333-333333333333", "--confirm"] },
|
||||
{
|
||||
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: "import_file_commit_apply",
|
||||
args: [
|
||||
"import-file",
|
||||
"commit",
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "import_file_discard_apply",
|
||||
args: [
|
||||
"import-file",
|
||||
"discard",
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "notes_process_apply",
|
||||
args: [
|
||||
"notes",
|
||||
"process",
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{ name: "permission_allow", args: ["permission", "allow"] },
|
||||
{ name: "permission_deny", args: ["permission", "deny"] },
|
||||
{ name: "extraction_set_apply", args: ["extraction", "set", "--window", "6m", "--confirm"] },
|
||||
{ name: "graph_width_apply", args: ["graph", "width", "--decision", "keep_existing", "--confirm"] },
|
||||
{ name: "processing_extract_apply", args: ["processing", "extract", "--confirm"] },
|
||||
{ name: "models_activate_apply", args: ["models", "activate", "44444444-4444-4444-8444-444444444444", "--confirm"] },
|
||||
{ name: "models_edit_apply", args: ["models", "edit", "44444444-4444-4444-8444-444444444444", "--config-file", "/nonexistent/patch.json", "--confirm"] },
|
||||
{
|
||||
name: "extraction_set_apply",
|
||||
args: ["extraction", "set", "--window", "6m", "--confirm"],
|
||||
},
|
||||
{
|
||||
name: "graph_width_apply",
|
||||
args: ["graph", "width", "--decision", "keep_existing", "--confirm"],
|
||||
},
|
||||
{
|
||||
name: "processing_extract_apply",
|
||||
args: ["processing", "extract", "--confirm"],
|
||||
},
|
||||
{
|
||||
name: "models_activate_apply",
|
||||
args: [
|
||||
"models",
|
||||
"activate",
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "models_edit_apply",
|
||||
args: [
|
||||
"models",
|
||||
"edit",
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
"--config-file",
|
||||
"/nonexistent/patch.json",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function validateCatalog(document) {
|
||||
@@ -201,7 +263,9 @@ export async function runPackageMatrix(runtimeDist, options = {}) {
|
||||
const markers = REQUIRED_MARKERS.filter(
|
||||
(marker) => !bundle.toString("utf8").includes(marker),
|
||||
);
|
||||
const isolatedRoot = await mkdtemp(path.join(tmpdir(), "memhub-package-matrix-"));
|
||||
const isolatedRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "memhub-package-matrix-"),
|
||||
);
|
||||
const home = path.join(isolatedRoot, "home");
|
||||
const dataDir = path.join(isolatedRoot, "data");
|
||||
const hostHome = path.join(isolatedRoot, "host-home");
|
||||
@@ -343,7 +407,10 @@ async function runStoppedDestructive(node, cli, environment) {
|
||||
`${probe.name} exit ${result.code}, expected 3 while Runtime is stopped`,
|
||||
);
|
||||
}
|
||||
if (document?.state !== "unknown" || document?.code !== "runtime_not_running") {
|
||||
if (
|
||||
document?.state !== "unknown" ||
|
||||
document?.code !== "runtime_not_running"
|
||||
) {
|
||||
failures.push(
|
||||
`${probe.name} did not fail closed with unknown/runtime_not_running`,
|
||||
);
|
||||
@@ -451,8 +518,7 @@ async function main() {
|
||||
}
|
||||
const asJson = rest.includes("--json");
|
||||
const expectShaIndex = rest.indexOf("--expect-sha");
|
||||
const expectSha =
|
||||
expectShaIndex >= 0 ? rest[expectShaIndex + 1] : undefined;
|
||||
const expectSha = expectShaIndex >= 0 ? rest[expectShaIndex + 1] : undefined;
|
||||
if (expectSha !== undefined && !/^[0-9a-fA-F]{64}$/.test(expectSha)) {
|
||||
process.stderr.write("--expect-sha must be a 64-character SHA-256\n");
|
||||
process.exitCode = 2;
|
||||
|
||||
@@ -30,12 +30,16 @@ describe("managed restore over HTTP", { timeout: 15_000 }, () => {
|
||||
}
|
||||
if (request.headers.authorization === "Bearer r409") {
|
||||
response.statusCode = 409;
|
||||
response.end(JSON.stringify({ detail: "backup_restore_runtime_running" }));
|
||||
response.end(
|
||||
JSON.stringify({ detail: "backup_restore_runtime_running" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.headers.authorization === "Bearer r422") {
|
||||
response.statusCode = 422;
|
||||
response.end(JSON.stringify({ detail: "backup_restore_source_outside_home" }));
|
||||
response.end(
|
||||
JSON.stringify({ detail: "backup_restore_source_outside_home" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.end(
|
||||
@@ -48,9 +52,12 @@ describe("managed restore over HTTP", { timeout: 15_000 }, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("no port");
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("no port");
|
||||
port = address.port;
|
||||
});
|
||||
|
||||
@@ -137,8 +144,18 @@ describe("maintenance Core identity", { timeout: 15_000 }, () => {
|
||||
|
||||
it("rejects a squatter, wrong service, wrong instance, and non-200", async () => {
|
||||
const cases = [
|
||||
{ status: 200, data: { status: "ok", service: "other", instance_id: "x" } },
|
||||
{ status: 200, data: { status: "ok", service: "one-memory-core", instance_id: "wrong" } },
|
||||
{
|
||||
status: 200,
|
||||
data: { status: "ok", service: "other", instance_id: "x" },
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
data: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
instance_id: "wrong",
|
||||
},
|
||||
},
|
||||
{ status: 503, data: {} },
|
||||
{ status: 200, data: null },
|
||||
];
|
||||
|
||||
@@ -91,7 +91,8 @@ export async function callRestore(
|
||||
source: string,
|
||||
request: typeof defaultPost = defaultPost,
|
||||
): Promise<
|
||||
{ state: "restored"; result: RestoreResult } | { state: "rejected"; code: string }
|
||||
| { state: "restored"; result: RestoreResult }
|
||||
| { state: "rejected"; code: string }
|
||||
> {
|
||||
const response = await request(
|
||||
`http://${options.host}:${options.port}/api/v1/backup/restore`,
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
batchScanExitCode,
|
||||
executeBatchImport,
|
||||
planBatchImport,
|
||||
renderBatchScanText,
|
||||
} from "./batch-import-flow.js";
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
|
||||
function readerFor(catalog: unknown): CoreReader {
|
||||
return (async (path: string) =>
|
||||
path === "/api/v1/sources"
|
||||
? ({ state: "ok", data: catalog } as ReadOutcome<unknown>)
|
||||
: ({ state: "not_found", code: "http_404" } as ReadOutcome<unknown>)) as CoreReader;
|
||||
: ({
|
||||
state: "not_found",
|
||||
code: "http_404",
|
||||
} as ReadOutcome<unknown>)) as CoreReader;
|
||||
}
|
||||
|
||||
function found(source_id: string, extra: Record<string, unknown> = {}) {
|
||||
@@ -81,18 +83,36 @@ describe("A19 batch execution", () => {
|
||||
readerFor([found("codex"), found("cursor")]),
|
||||
);
|
||||
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: ["codex", "cursor"],
|
||||
failed_sources: {},
|
||||
jobs: [
|
||||
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 3, stored: 3, errors: 0, operation: "history" },
|
||||
{ job_id: "j2", source_id: "cursor", status: "pending", discovered: 5, stored: 5, errors: 0, operation: "history" },
|
||||
],
|
||||
},
|
||||
}));
|
||||
const post = vi.fn(
|
||||
async (_path: string, body: Record<string, unknown>) => ({
|
||||
state: "committed" as const,
|
||||
data: {
|
||||
request_id: body.request_id,
|
||||
accepted_source_ids: ["codex", "cursor"],
|
||||
failed_sources: {},
|
||||
jobs: [
|
||||
{
|
||||
job_id: "j1",
|
||||
source_id: "codex",
|
||||
status: "pending",
|
||||
discovered: 3,
|
||||
stored: 3,
|
||||
errors: 0,
|
||||
operation: "history",
|
||||
},
|
||||
{
|
||||
job_id: "j2",
|
||||
source_id: "cursor",
|
||||
status: "pending",
|
||||
discovered: 5,
|
||||
stored: 5,
|
||||
errors: 0,
|
||||
operation: "history",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const operations = { post } as unknown as CoreOperations;
|
||||
const document = await executeBatchImport(
|
||||
planned.candidates,
|
||||
@@ -127,7 +147,15 @@ describe("A19 batch execution", () => {
|
||||
accepted_source_ids: ["codex"],
|
||||
failed_sources: { cursor: "source_identity_changed" },
|
||||
jobs: [
|
||||
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 1, stored: 1, errors: 0, operation: "history" },
|
||||
{
|
||||
job_id: "j1",
|
||||
source_id: "codex",
|
||||
status: "pending",
|
||||
discovered: 1,
|
||||
stored: 1,
|
||||
errors: 0,
|
||||
operation: "history",
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
@@ -242,7 +270,15 @@ describe("A19 batch execution", () => {
|
||||
accepted_source_ids: ["codex"],
|
||||
failed_sources: {},
|
||||
jobs: [
|
||||
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 1, stored: 1, errors: 0, operation: "history" },
|
||||
{
|
||||
job_id: "j1",
|
||||
source_id: "codex",
|
||||
status: "pending",
|
||||
discovered: 1,
|
||||
stored: 1,
|
||||
errors: 0,
|
||||
operation: "history",
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -14,15 +14,14 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { buildSourceImportRequest } from "@one-memory/operations";
|
||||
|
||||
import type { ImportJob } from "@one-memory/contracts";
|
||||
import { buildSourceImportRequest } from "@one-memory/operations";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
type SourceConnectionCandidate,
|
||||
validateSourceConnectionCandidates,
|
||||
} from "./core-responses.js";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
export interface BatchSourceRow {
|
||||
@@ -57,19 +56,34 @@ export function detectedSourceIds(
|
||||
return candidates.map((candidate) => candidate.source_id);
|
||||
}
|
||||
|
||||
export async function planBatchImport(
|
||||
reader: CoreReader,
|
||||
): Promise<
|
||||
| { state: "preview"; candidates: SourceConnectionCandidate[]; rows: BatchSourceRow[]; notFound: string[] }
|
||||
| { state: "rejected" | "unknown" | "no_sources"; code: string; notFound: string[] }
|
||||
export async function planBatchImport(reader: CoreReader): Promise<
|
||||
| {
|
||||
state: "preview";
|
||||
candidates: SourceConnectionCandidate[];
|
||||
rows: BatchSourceRow[];
|
||||
notFound: string[];
|
||||
}
|
||||
| {
|
||||
state: "rejected" | "unknown" | "no_sources";
|
||||
code: string;
|
||||
notFound: string[];
|
||||
}
|
||||
> {
|
||||
const outcome = await reader<unknown>("/api/v1/sources");
|
||||
if (outcome.state !== "ok") {
|
||||
return { state: outcome.state === "unavailable" ? "unknown" : "rejected", code: outcome.code, notFound: [] };
|
||||
return {
|
||||
state: outcome.state === "unavailable" ? "unknown" : "rejected",
|
||||
code: outcome.code,
|
||||
notFound: [],
|
||||
};
|
||||
}
|
||||
const catalog = outcome.data;
|
||||
if (!Array.isArray(catalog) || catalog.length > SOURCE_LIST_LIMIT) {
|
||||
return { state: "rejected", code: "sources_response_invalid", notFound: [] };
|
||||
return {
|
||||
state: "rejected",
|
||||
code: "sources_response_invalid",
|
||||
notFound: [],
|
||||
};
|
||||
}
|
||||
const candidates = validateSourceConnectionCandidates(catalog);
|
||||
if (!candidates) {
|
||||
@@ -165,9 +179,7 @@ export async function executeBatchImport(
|
||||
receipt.failed_sources && typeof receipt.failed_sources === "object"
|
||||
? (receipt.failed_sources as Record<string, string>)
|
||||
: {};
|
||||
const jobs = Array.isArray(receipt.jobs)
|
||||
? (receipt.jobs as ImportJob[])
|
||||
: [];
|
||||
const jobs = Array.isArray(receipt.jobs) ? (receipt.jobs as ImportJob[]) : [];
|
||||
const firstSeen = jobs.map((job) => ({
|
||||
source_id: job.source_id,
|
||||
discovered: job.discovered,
|
||||
@@ -262,7 +274,8 @@ export async function executeBatchImport(
|
||||
}
|
||||
|
||||
export function batchScanExitCode(document: BatchScanDocument): number {
|
||||
if (document.state === "committed" || document.state === "no_sources") return 0;
|
||||
if (document.state === "committed" || document.state === "no_sources")
|
||||
return 0;
|
||||
if (document.state === "partial") return 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -282,11 +282,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
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,
|
||||
],
|
||||
options: [PLAN_FILE_OPTION, EXPLICIT_CONFIRM_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -300,11 +300,7 @@ describe("CLI-22 shared output contract", () => {
|
||||
`${PRODUCT} sync`,
|
||||
],
|
||||
[
|
||||
[
|
||||
"sync",
|
||||
"receipt",
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
],
|
||||
["sync", "receipt", "123e4567-e89b-12d3-a456-426614174000"],
|
||||
`${PRODUCT} sync`,
|
||||
],
|
||||
[["sync", "pause", "--source"], `${PRODUCT} sync`],
|
||||
@@ -555,12 +551,7 @@ describe("documented commands", () => {
|
||||
// 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"],
|
||||
jobs: [
|
||||
"list",
|
||||
"show",
|
||||
"start",
|
||||
"<pause|resume|cancel>",
|
||||
],
|
||||
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>"],
|
||||
|
||||
@@ -342,17 +342,20 @@ describe("unified context search", () => {
|
||||
["--limit", "1", "2"],
|
||||
["--character-budget", "200", "300"],
|
||||
["--timeout-ms", "100", "200"],
|
||||
])("rejects a repeated %s before Runtime attach", async (flag, first, second) => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
await run(["recall", "q", flag, first, flag, second, "--json"], {}),
|
||||
).toBe(2);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toBe("");
|
||||
});
|
||||
])(
|
||||
"rejects a repeated %s before Runtime attach",
|
||||
async (flag, first, second) => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
await run(["recall", "q", flag, first, flag, second, "--json"], {}),
|
||||
).toBe(2);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses malformed success bodies instead of leaking nested values", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { classifyModelRemove } from "@one-memory/operations";
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Only paths proven side-effect free by reading the Core handlers belong here.
|
||||
@@ -56,7 +56,10 @@ export function createUiSessionRequest(attachment: {
|
||||
return { state: "ok" as const, data: response.data };
|
||||
}
|
||||
if (response.status === 503) {
|
||||
return { state: "unavailable" as const, code: "ui_web_assets_unavailable" };
|
||||
return {
|
||||
state: "unavailable" as const,
|
||||
code: "ui_web_assets_unavailable",
|
||||
};
|
||||
}
|
||||
return { state: "unavailable" as const, code: `http_${response.status}` };
|
||||
};
|
||||
@@ -397,18 +400,23 @@ export async function readMemoryWrite(
|
||||
|
||||
export async function readScanRoots(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
): Promise<{ schema_version: number; revision: number; roots: unknown[] } | null> {
|
||||
): Promise<{
|
||||
schema_version: number;
|
||||
revision: number;
|
||||
roots: unknown[];
|
||||
} | null> {
|
||||
try {
|
||||
const response = await axios.get<{ schema_version: number; revision: number; roots: unknown[] }>(
|
||||
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
const response = await axios.get<{
|
||||
schema_version: number;
|
||||
revision: number;
|
||||
roots: unknown[];
|
||||
}>(`${baseUrlOf(attachment)}/api/v1/scan-roots`, {
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
});
|
||||
return response.data;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -420,13 +428,17 @@ export async function addScanRoot(
|
||||
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,
|
||||
});
|
||||
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) };
|
||||
@@ -438,14 +450,17 @@ export async function removeScanRoot(
|
||||
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,
|
||||
});
|
||||
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) };
|
||||
@@ -456,16 +471,16 @@ export async function readAdapters(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
): Promise<{ schema_version: number; adapters: unknown[] } | null> {
|
||||
try {
|
||||
const response = await axios.get<{ schema_version: number; adapters: unknown[] }>(
|
||||
`${baseUrlOf(attachment)}/api/v1/adapters`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
const response = await axios.get<{
|
||||
schema_version: number;
|
||||
adapters: unknown[];
|
||||
}>(`${baseUrlOf(attachment)}/api/v1/adapters`, {
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
});
|
||||
return response.data;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -23,14 +23,22 @@ describe("explicit data deletion", () => {
|
||||
"one-memory.sqlite3": "db",
|
||||
"runtime-service.json": "{}",
|
||||
});
|
||||
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
|
||||
const plan = await planDataDeletion({
|
||||
dataDir,
|
||||
home,
|
||||
runtimeRunning: false,
|
||||
});
|
||||
expect(plan.state).toBe("preview");
|
||||
expect(plan.entries).toContain(path.join(dataDir, "one-memory.sqlite3"));
|
||||
});
|
||||
|
||||
it("refuses while the Runtime is still running", async () => {
|
||||
const { home, dataDir } = await homeWith();
|
||||
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: true });
|
||||
const plan = await planDataDeletion({
|
||||
dataDir,
|
||||
home,
|
||||
runtimeRunning: true,
|
||||
});
|
||||
expect(plan).toMatchObject({
|
||||
state: "blocked",
|
||||
code: "runtime_still_running",
|
||||
@@ -66,16 +74,23 @@ describe("explicit data deletion", () => {
|
||||
home,
|
||||
runtimeRunning: false,
|
||||
});
|
||||
expect(plan).toMatchObject({ state: "blocked", code: "data_dir_refused" });
|
||||
expect(plan).toMatchObject({
|
||||
state: "blocked",
|
||||
code: "data_dir_refused",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("requires the second confirmation to name the exact directory", async () => {
|
||||
const { home, dataDir } = await homeWith();
|
||||
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
|
||||
await expect(applyDataDeletion(plan, path.join(home, "other"))).rejects.toThrow(
|
||||
"data_deletion_confirmation_mismatch",
|
||||
);
|
||||
const plan = await planDataDeletion({
|
||||
dataDir,
|
||||
home,
|
||||
runtimeRunning: false,
|
||||
});
|
||||
await expect(
|
||||
applyDataDeletion(plan, path.join(home, "other")),
|
||||
).rejects.toThrow("data_deletion_confirmation_mismatch");
|
||||
// The data survived a mismatched confirmation.
|
||||
await expect(
|
||||
stat(path.join(dataDir, "one-memory.sqlite3")),
|
||||
@@ -84,7 +99,11 @@ describe("explicit data deletion", () => {
|
||||
|
||||
it("deletes an owned Home only when both confirmations match", async () => {
|
||||
const { home, dataDir } = await homeWith({ "one-memory.sqlite3": "db" });
|
||||
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
|
||||
const plan = await planDataDeletion({
|
||||
dataDir,
|
||||
home,
|
||||
runtimeRunning: false,
|
||||
});
|
||||
const receipt = await applyDataDeletion(plan, dataDir);
|
||||
expect(receipt.state).toBe("deleted");
|
||||
await expect(stat(dataDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
@@ -68,7 +68,8 @@ export function renderDoctorText(document: DoctorDocument): string {
|
||||
return [
|
||||
`MemHub 检查:${doctorState(document.state)}`,
|
||||
...document.checks.map(
|
||||
(check) => `${symbol(check.state)} ${labels[check.id]}:${checkState(check.state)}`,
|
||||
(check) =>
|
||||
`${symbol(check.state)} ${labels[check.id]}:${checkState(check.state)}`,
|
||||
),
|
||||
...(document.state === "healthy"
|
||||
? []
|
||||
|
||||
@@ -28,11 +28,7 @@ export function resolveEntryDiagnostics(input: {
|
||||
// comparison would misread that as isolated and silently drop diagnostics.
|
||||
const home = resolve(input.home);
|
||||
const accountHome = resolve(input.accountHome);
|
||||
if (
|
||||
home === undefined ||
|
||||
accountHome === undefined ||
|
||||
home === accountHome
|
||||
) {
|
||||
if (home === undefined || accountHome === undefined || home === accountHome) {
|
||||
return { kind: "log" };
|
||||
}
|
||||
// Isolated HOME with no explicit data dir: the fallback path would write
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
resolveRuntimeCliConfig,
|
||||
} from "@one-memory/runtime-control";
|
||||
import { run } from "./cli.js";
|
||||
import { resolveDiagnosticsDataDir } from "./install-receipt.js";
|
||||
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
|
||||
import { resolveDiagnosticsDataDir } from "./install-receipt.js";
|
||||
|
||||
function accountHomeDirectory(): string | undefined {
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { ImportJob, ModelProfile } from "@one-memory/contracts";
|
||||
import {
|
||||
ModelProfileInputError,
|
||||
SourceImportInputError,
|
||||
buildModelProfileCreateRequest,
|
||||
buildSourceImportRequest,
|
||||
ModelProfileInputError,
|
||||
parseModelProfileReceipt,
|
||||
SourceImportInputError,
|
||||
} from "@one-memory/operations";
|
||||
import type { ImportJob, ModelProfile } from "@one-memory/contracts";
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
@@ -117,9 +117,7 @@ function classifySourceImport(
|
||||
product: PRODUCT,
|
||||
state: "rejected",
|
||||
operation_id: operationId,
|
||||
code: onlyBusy
|
||||
? "source_import_all_busy"
|
||||
: "source_import_all_rejected",
|
||||
code: onlyBusy ? "source_import_all_busy" : "source_import_all_rejected",
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,7 +266,9 @@ export function renderHostText(document: HostFlowDocument): string {
|
||||
}
|
||||
if (review.length > 12) lines.push(` … 另有 ${review.length - 12} 处`);
|
||||
lines.push("");
|
||||
lines.push("这些不是我们安装的内容,卸载不会替你删除;需自行处理后才能卸载。");
|
||||
lines.push(
|
||||
"这些不是我们安装的内容,卸载不会替你删除;需自行处理后才能卸载。",
|
||||
);
|
||||
}
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
type WizardPrompter,
|
||||
} from "./init-wizard.js";
|
||||
|
||||
function scripted(lines: string[]): WizardPrompter & { asked: string[]; wrote: string[] } {
|
||||
function scripted(
|
||||
lines: string[],
|
||||
): WizardPrompter & { asked: string[]; wrote: string[] } {
|
||||
let index = 0;
|
||||
const asked: string[] = [];
|
||||
const wrote: string[] = [];
|
||||
|
||||
@@ -99,8 +99,7 @@ export async function runInitWizard(
|
||||
"开启处理?开启后会处理历史未完成会话并持续接收新会话;输入 n 保持冻结。[Y/n]: ",
|
||||
);
|
||||
const processing = parseYesNo(processingAnswer, true);
|
||||
if (processing === null)
|
||||
return { state: "invalid", code: "invalid_answer" };
|
||||
if (processing === null) return { state: "invalid", code: "invalid_answer" };
|
||||
|
||||
return {
|
||||
state: "answered",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
|
||||
import {
|
||||
defaultInstallReceiptPath,
|
||||
readInstallReceipt,
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
resolveInstalledDataDir,
|
||||
writeInstallReceipt,
|
||||
} from "./install-receipt.js";
|
||||
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
|
||||
|
||||
async function root(): Promise<string> {
|
||||
return mkdtemp(path.join(tmpdir(), "memhub-install-receipt-"));
|
||||
@@ -230,9 +229,9 @@ describe("managed install receipt", () => {
|
||||
}),
|
||||
).toBe("/tmp/env data");
|
||||
// Receipt beats default.
|
||||
expect(
|
||||
await resolveDiagnosticsDataDir(["install"], { HOME: home }),
|
||||
).toBe(path.join(home, "receipt data"));
|
||||
expect(await resolveDiagnosticsDataDir(["install"], { HOME: home })).toBe(
|
||||
path.join(home, "receipt data"),
|
||||
);
|
||||
// Invalid explicit flag must not silently fall back to the receipt.
|
||||
expect(
|
||||
await resolveDiagnosticsDataDir(
|
||||
|
||||
@@ -184,10 +184,9 @@ describe("interactive model setup", () => {
|
||||
embedding_provider_kind: "local_onnx",
|
||||
}),
|
||||
);
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/api/v1/model-content-permission",
|
||||
{ allowed: true },
|
||||
);
|
||||
expect(api.post).toHaveBeenCalledWith("/api/v1/model-content-permission", {
|
||||
allowed: true,
|
||||
});
|
||||
expect(prompt.written.join("\n")).not.toContain("sk-do-not-print");
|
||||
});
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ import {
|
||||
} from "./headless-flows.js";
|
||||
import { approvalFingerprint, listHosts, readHost } from "./host-flow.js";
|
||||
import { parseSelection, type WizardPrompter } from "./init-wizard.js";
|
||||
import { setModelContentPermission } from "./model-content-permission-flow.js";
|
||||
import { executeModelProbe, planModelProbe } from "./model-probe-flow.js";
|
||||
import {
|
||||
executeModelProfileActivate,
|
||||
planModelProfileActivate,
|
||||
} from "./model-write-flows.js";
|
||||
import { setModelContentPermission } from "./model-content-permission-flow.js";
|
||||
|
||||
type ProviderPreset = {
|
||||
id: string;
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("axios", () => ({
|
||||
default: { create: vi.fn(() => transport), get: vi.fn() },
|
||||
}));
|
||||
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { createCoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
executeJobAction,
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
planJobAction,
|
||||
planJobStart,
|
||||
} from "./job-flow.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
|
||||
function operations(
|
||||
post: (path: string, body: Record<string, unknown>) => Promise<unknown>,
|
||||
|
||||
@@ -145,9 +145,7 @@ function jobMutationResult(
|
||||
export function jobExitCode(document: JobFlowDocument): number {
|
||||
if (document.state === "preview" || document.state === "committed") return 0;
|
||||
if (document.state === "recovered") return 0;
|
||||
return document.state === "rejected" || document.state === "unknown"
|
||||
? 1
|
||||
: 2;
|
||||
return document.state === "rejected" || document.state === "unknown" ? 1 : 2;
|
||||
}
|
||||
|
||||
function invalid(code: string): JobFlowDocument {
|
||||
|
||||
@@ -105,10 +105,7 @@ describe("memhub jobs", () => {
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["jobs", "start", "--source", "codex", "--json"],
|
||||
{},
|
||||
);
|
||||
code = await run(["jobs", "start", "--source", "codex", "--json"], {});
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
@@ -154,10 +151,7 @@ describe("memhub jobs", () => {
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["jobs", action, "--job", "job-1", "--json"],
|
||||
{},
|
||||
);
|
||||
code = await run(["jobs", action, "--job", "job-1", "--json"], {});
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
|
||||
import { diagnosticLogFiles, readDiagnosticEvents } from "@one-memory/runtime-control";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
|
||||
import {
|
||||
diagnosticLogFiles,
|
||||
readDiagnosticEvents,
|
||||
} from "@one-memory/runtime-control";
|
||||
|
||||
export interface LogQuery {
|
||||
since?: Date;
|
||||
@@ -160,10 +163,21 @@ export async function readLogReport(
|
||||
const logsDirectory = path.join(path.resolve(dataDir), "logs");
|
||||
const info = await lstat(logsDirectory).catch(() => null);
|
||||
if (info !== null && (!info.isDirectory() || info.isSymbolicLink())) {
|
||||
return { events: [], invalid_lines: 0, failure: "log_directory_unreadable" };
|
||||
return {
|
||||
events: [],
|
||||
invalid_lines: 0,
|
||||
failure: "log_directory_unreadable",
|
||||
};
|
||||
}
|
||||
if (info !== null && (await diagnosticLogFiles(dataDir).catch(() => null)) === null) {
|
||||
return { events: [], invalid_lines: 0, failure: "log_directory_unreadable" };
|
||||
if (
|
||||
info !== null &&
|
||||
(await diagnosticLogFiles(dataDir).catch(() => null)) === null
|
||||
) {
|
||||
return {
|
||||
events: [],
|
||||
invalid_lines: 0,
|
||||
failure: "log_directory_unreadable",
|
||||
};
|
||||
}
|
||||
const explicit =
|
||||
query.since ||
|
||||
@@ -239,15 +253,27 @@ export function redactDiagnosticEvent(
|
||||
outcome: event.outcome,
|
||||
session_id: event.session_id,
|
||||
...(event.trace_id === undefined ? {} : { trace_id: event.trace_id }),
|
||||
...(event.incident_id === undefined ? {} : { incident_id: event.incident_id }),
|
||||
...(event.instance_id === undefined ? {} : { instance_id: event.instance_id }),
|
||||
...(event.operation_id === undefined ? {} : { operation_id: event.operation_id }),
|
||||
...(event.incident_id === undefined
|
||||
? {}
|
||||
: { incident_id: event.incident_id }),
|
||||
...(event.instance_id === undefined
|
||||
? {}
|
||||
: { instance_id: event.instance_id }),
|
||||
...(event.operation_id === undefined
|
||||
? {}
|
||||
: { operation_id: event.operation_id }),
|
||||
...(event.request_id === undefined ? {} : { request_id: event.request_id }),
|
||||
...(event.job_id === undefined ? {} : { job_id: event.job_id }),
|
||||
...(event.task_id === undefined ? {} : { task_id: event.task_id }),
|
||||
...(event.duration_ms === undefined ? {} : { duration_ms: event.duration_ms }),
|
||||
...(event.state_before === undefined ? {} : { state_before: event.state_before }),
|
||||
...(event.state_after === undefined ? {} : { state_after: event.state_after }),
|
||||
...(event.duration_ms === undefined
|
||||
? {}
|
||||
: { duration_ms: event.duration_ms }),
|
||||
...(event.state_before === undefined
|
||||
? {}
|
||||
: { state_before: event.state_before }),
|
||||
...(event.state_after === undefined
|
||||
? {}
|
||||
: { state_after: event.state_after }),
|
||||
...(event.error_code === undefined ? {} : { error_code: event.error_code }),
|
||||
...(event.error_type === undefined ? {} : { error_type: event.error_type }),
|
||||
...(event.safe_stack === undefined
|
||||
|
||||
@@ -11,9 +11,8 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CliLinkPlan } from "@one-memory/runtime-control";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
installManagedCliAlias,
|
||||
installManagedCliLink,
|
||||
@@ -107,7 +106,9 @@ describe("managed memhub CLI alias", () => {
|
||||
await installManagedCliAlias(plan);
|
||||
|
||||
await expect(removeManagedCliAlias(plan)).resolves.toBe("removed");
|
||||
await expect(lstat(plan.link_path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(lstat(plan.link_path)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
// C8 P1 (2026-09-29): `runtime-control.applyLocalUninstall` deletes
|
||||
@@ -156,7 +157,10 @@ describe("managed memhub CLI alias", () => {
|
||||
it("manages the one-memory companion when memhub is already present", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const f = await fixture();
|
||||
const primary = { ...f.base, link_path: path.join(f.base.bin_directory, "memhub") };
|
||||
const primary = {
|
||||
...f.base,
|
||||
link_path: path.join(f.base.bin_directory, "memhub"),
|
||||
};
|
||||
await mkdir(f.base.bin_directory, { recursive: true, mode: 0o700 });
|
||||
await (await import("node:fs/promises")).chmod(f.base.bin_directory, 0o700);
|
||||
await (await import("node:fs/promises")).symlink(
|
||||
@@ -168,7 +172,10 @@ describe("managed memhub CLI alias", () => {
|
||||
const plan = await planManagedCliLink(f.base, "one-memory");
|
||||
// The companion does not exist yet, so it is ours to create; crucially it
|
||||
// targets the compat name, not the package's `memhub`.
|
||||
expect(plan).toMatchObject({ status: "create", link_path: f.base.link_path });
|
||||
expect(plan).toMatchObject({
|
||||
status: "create",
|
||||
link_path: f.base.link_path,
|
||||
});
|
||||
|
||||
// The package's memhub link is untouched.
|
||||
expect(await readlink(primary.link_path)).toBe(f.target);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { access, chmod, lstat, mkdir, readlink, rm, symlink } from "node:fs/promises";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
readlink,
|
||||
rm,
|
||||
symlink,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { CliLinkPlan, CliLinkReceipt } from "@one-memory/runtime-control";
|
||||
|
||||
@@ -53,7 +53,9 @@ describe("model content permission CLI", () => {
|
||||
expect(permission.code).toBe(0);
|
||||
expect(permission.stdout).toContain("memhub permission allow");
|
||||
expect(permission.stdout).toContain("memhub permission deny");
|
||||
expect(permission.stdout).not.toMatch(/\b(preview|confirm|process|revoke)\b/);
|
||||
expect(permission.stdout).not.toMatch(
|
||||
/\b(preview|confirm|process|revoke)\b/,
|
||||
);
|
||||
expect(permission.stdout).not.toContain("revision");
|
||||
expect(permission.stdout).not.toContain("receipt");
|
||||
|
||||
|
||||
@@ -23,19 +23,22 @@ describe("model content permission flow", () => {
|
||||
it.each([
|
||||
["allow", true],
|
||||
["deny", false],
|
||||
] as const)("sets %s directly without revision or receipt fields", async (_, allowed) => {
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
state: "committed",
|
||||
data: { allowed },
|
||||
});
|
||||
] as const)(
|
||||
"sets %s directly without revision or receipt fields",
|
||||
async (_, allowed) => {
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
state: "committed",
|
||||
data: { allowed },
|
||||
});
|
||||
|
||||
await expect(
|
||||
setModelContentPermission({ post } as CoreOperations, allowed),
|
||||
).resolves.toMatchObject({ state: "ok", allowed });
|
||||
expect(post).toHaveBeenCalledWith("/api/v1/model-content-permission", {
|
||||
allowed,
|
||||
});
|
||||
});
|
||||
await expect(
|
||||
setModelContentPermission({ post } as CoreOperations, allowed),
|
||||
).resolves.toMatchObject({ state: "ok", allowed });
|
||||
expect(post).toHaveBeenCalledWith("/api/v1/model-content-permission", {
|
||||
allowed,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not accept a malformed or mismatched success response", async () => {
|
||||
const malformed = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -23,7 +23,10 @@ export async function readModelContentPermission(
|
||||
if (outcome.state !== "ok") {
|
||||
return failure("unknown", outcome.code);
|
||||
}
|
||||
return permissionState(outcome.data, "model_content_permission_response_invalid");
|
||||
return permissionState(
|
||||
outcome.data,
|
||||
"model_content_permission_response_invalid",
|
||||
);
|
||||
}
|
||||
|
||||
export async function setModelContentPermission(
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { ModelProfile } from "@one-memory/contracts";
|
||||
import {
|
||||
ModelProfileInputError,
|
||||
buildModelProfileUpdateRequest,
|
||||
ModelProfileInputError,
|
||||
parseModelProfileReceipt,
|
||||
} from "@one-memory/operations";
|
||||
import type { CoreOperations, CorePatchOperations } from "./core-operations.js";
|
||||
@@ -78,7 +78,10 @@ function digestOf(value: unknown): string {
|
||||
|
||||
function preview(
|
||||
plan:
|
||||
| Omit<Extract<ModelWritePlan, { kind: "model_profile_activate" }>, "digest">
|
||||
| Omit<
|
||||
Extract<ModelWritePlan, { kind: "model_profile_activate" }>,
|
||||
"digest"
|
||||
>
|
||||
| Omit<Extract<ModelWritePlan, { kind: "model_profile_edit" }>, "digest">,
|
||||
): ModelWriteDocument {
|
||||
return {
|
||||
@@ -198,9 +201,9 @@ export async function executeModelProfileEdit(
|
||||
let request: ReturnType<typeof buildModelProfileUpdateRequest>;
|
||||
try {
|
||||
request = buildModelProfileUpdateRequest(plan.profile_id, {
|
||||
...plan.patch,
|
||||
expected_revision: plan.expected_revision,
|
||||
...(apiKey === undefined ? {} : { api_key: apiKey }),
|
||||
...plan.patch,
|
||||
expected_revision: plan.expected_revision,
|
||||
...(apiKey === undefined ? {} : { api_key: apiKey }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ModelProfileInputError) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir, homedir } from "node:os";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -103,26 +103,45 @@ describe("unknown flags are rejected", () => {
|
||||
});
|
||||
|
||||
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",
|
||||
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", ["remove", "--root", "/tmp/x", "--source", "codex"]),
|
||||
parseProductCommand("path", [
|
||||
"remove",
|
||||
"--root",
|
||||
"/tmp/x",
|
||||
"--source",
|
||||
"codex",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("path", ["remove", "--root", "/tmp/x", "--no-recursive"]),
|
||||
parseProductCommand("path", [
|
||||
"remove",
|
||||
"--root",
|
||||
"/tmp/x",
|
||||
"--no-recursive",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("path", ["add", "--root", "/tmp/x", "--full"]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", ["create", "--target", "/tmp/b", "--source", "/tmp/s"]),
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/b",
|
||||
"--source",
|
||||
"/tmp/s",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", [
|
||||
@@ -163,9 +182,7 @@ describe("unknown flags are rejected", () => {
|
||||
});
|
||||
|
||||
it("rejects repeated boolean flags instead of treating them as idempotent", () => {
|
||||
expect(
|
||||
parseProductCommand("scan", ["--all", "--all", "--yes"]),
|
||||
).toBeNull();
|
||||
expect(parseProductCommand("scan", ["--all", "--all", "--yes"])).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("scan", ["--full", "--full", "--yes"]),
|
||||
).toBeNull();
|
||||
@@ -179,7 +196,13 @@ describe("unknown flags are rejected", () => {
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", ["create", "--target", "/tmp/a", "--yes", "--yes"]),
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/a",
|
||||
"--yes",
|
||||
"--yes",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -215,12 +238,12 @@ describe("unknown flags are rejected", () => {
|
||||
]);
|
||||
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", parsed?.confirmPath),
|
||||
).toBe(true);
|
||||
expect(
|
||||
restoreConfirmationMatches("/tmp/backups/a.sqlite3", undefined),
|
||||
).toBe(false);
|
||||
expect(
|
||||
restoreConfirmationMatches(
|
||||
"/tmp/backups/a.sqlite3",
|
||||
@@ -365,7 +388,11 @@ describe("path add and remove carry what the endpoint needs", () => {
|
||||
});
|
||||
|
||||
it("distinguishes remove from add", () => {
|
||||
const parsed = parseProductCommand("path", ["remove", "--root", "/tmp/scanme"]);
|
||||
const parsed = parseProductCommand("path", [
|
||||
"remove",
|
||||
"--root",
|
||||
"/tmp/scanme",
|
||||
]);
|
||||
if (parsed === null) {
|
||||
throw new Error("path remove must parse");
|
||||
}
|
||||
@@ -375,7 +402,12 @@ describe("path add and remove carry what the endpoint needs", () => {
|
||||
});
|
||||
|
||||
it("does not silently drop --yes", () => {
|
||||
const parsed = parseProductCommand("path", ["add", "--root", "/tmp/a", "--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.
|
||||
|
||||
@@ -6,10 +6,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/** The app root (one level above this test's `src/`). */
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const bundle = path.join(root, "dist", "one-memory.cjs");
|
||||
|
||||
// The published npm package must be self-contained. The bundle inlines
|
||||
@@ -20,9 +17,10 @@ const bundle = path.join(root, "dist", "one-memory.cjs");
|
||||
describe("publish manifest is production-safe", () => {
|
||||
it("declares no npm runtime dependency once dependencies are inlined", () => {
|
||||
// `test` builds with tsup first, so a missing bundle is a real failure.
|
||||
expect(existsSync(bundle), "run `pnpm --filter @one-memory/cli build`").toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
existsSync(bundle),
|
||||
"run `pnpm --filter @one-memory/cli build`",
|
||||
).toBe(true);
|
||||
const source = readFileSync(bundle, "utf8");
|
||||
const external = [
|
||||
...new Set(
|
||||
@@ -37,16 +35,15 @@ describe("publish manifest is production-safe", () => {
|
||||
),
|
||||
];
|
||||
// Every one of these must be a Node builtin, i.e. no npm package.
|
||||
const npmDeps = external.filter(
|
||||
(name) => !builtinModules.includes(name),
|
||||
);
|
||||
const npmDeps = external.filter((name) => !builtinModules.includes(name));
|
||||
expect(npmDeps).toEqual([]);
|
||||
});
|
||||
|
||||
it("strip-out rule keeps only what the bundle requires", () => {
|
||||
expect(existsSync(bundle), "run `pnpm --filter @one-memory/cli build`").toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
existsSync(bundle),
|
||||
"run `pnpm --filter @one-memory/cli build`",
|
||||
).toBe(true);
|
||||
// Run the real generator, then inspect its output manifest.
|
||||
execFileSync("node", ["scripts/prepare-publish-manifest.mjs"], {
|
||||
cwd: root,
|
||||
|
||||
@@ -49,9 +49,9 @@ describe("runtime service lock guard", () => {
|
||||
JSON.stringify({ pid: 4321 }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await expect(
|
||||
inspectRuntimeServiceLock(dataDir, () => false),
|
||||
).resolves.toBe("clear");
|
||||
await expect(inspectRuntimeServiceLock(dataDir, () => false)).resolves.toBe(
|
||||
"clear",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on a malformed lease", async () => {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type CommandResult,
|
||||
inspectLaunchdService,
|
||||
restartManagedService,
|
||||
type ServiceCommandRunner,
|
||||
startManagedService,
|
||||
stopManagedService,
|
||||
type CommandResult,
|
||||
type ServiceCommandRunner,
|
||||
} from "./service-session.js";
|
||||
|
||||
const SERVICE_ID = "com.tencent.onememory.runtime";
|
||||
@@ -35,7 +35,11 @@ describe("service-aware launchd session", () => {
|
||||
it("reports running with the launchd pid and plist", async () => {
|
||||
const run = vi.fn<ServiceCommandRunner>(async () => running(84237));
|
||||
await expect(
|
||||
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
inspectLaunchdService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "running",
|
||||
pid: 84237,
|
||||
@@ -46,7 +50,11 @@ describe("service-aware launchd session", () => {
|
||||
it("treats 'could not find service' as absent", async () => {
|
||||
const run = vi.fn<ServiceCommandRunner>(async () => absent());
|
||||
await expect(
|
||||
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
inspectLaunchdService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toEqual({ state: "absent" });
|
||||
});
|
||||
|
||||
@@ -57,7 +65,11 @@ describe("service-aware launchd session", () => {
|
||||
stderr: "Input/output error",
|
||||
}));
|
||||
await expect(
|
||||
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
inspectLaunchdService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toEqual({ state: "unknown", code: "launchd_inspection_failed" });
|
||||
});
|
||||
|
||||
@@ -146,13 +158,21 @@ describe("service-aware launchd session", () => {
|
||||
if (command[1] === "print") {
|
||||
printed += 1;
|
||||
return printed === 1
|
||||
? { code: 0, stdout: `path = /tmp/x.plist\nstate = not running\n`, stderr: "" }
|
||||
? {
|
||||
code: 0,
|
||||
stdout: `path = /tmp/x.plist\nstate = not running\n`,
|
||||
stderr: "",
|
||||
}
|
||||
: running(84237);
|
||||
}
|
||||
return running(84237);
|
||||
});
|
||||
await expect(
|
||||
startManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
startManagedService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toBe("started");
|
||||
expect(calls).toContainEqual([
|
||||
"launchctl",
|
||||
@@ -169,7 +189,11 @@ describe("service-aware launchd session", () => {
|
||||
return running(84237);
|
||||
});
|
||||
await expect(
|
||||
startManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
startManagedService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toBe("started");
|
||||
// Already running -> no kickstart needed.
|
||||
expect(calls.some((c) => c[1] === "kickstart")).toBe(false);
|
||||
@@ -214,7 +238,11 @@ describe("service-aware launchd session", () => {
|
||||
stderr: "",
|
||||
}));
|
||||
await expect(
|
||||
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
inspectLaunchdService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toEqual({ state: "loaded", plistPath: "/tmp/x.plist" });
|
||||
});
|
||||
|
||||
@@ -225,7 +253,11 @@ describe("service-aware launchd session", () => {
|
||||
return running(101);
|
||||
});
|
||||
await expect(
|
||||
restartManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
restartManagedService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toBe("restarted");
|
||||
expect(calls).toContainEqual([
|
||||
"launchctl",
|
||||
@@ -286,7 +318,9 @@ describe("service-aware launchd session", () => {
|
||||
stdout: JSON.stringify({
|
||||
Label: SERVICE_ID,
|
||||
ProgramArguments: ["/tmp/node", "/tmp/runtime.cjs", "serve"],
|
||||
EnvironmentVariables: { ONE_MEMORY_DATA_DIR: "/tmp/this-install/data" },
|
||||
EnvironmentVariables: {
|
||||
ONE_MEMORY_DATA_DIR: "/tmp/this-install/data",
|
||||
},
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
@@ -49,7 +49,10 @@ function launchdDomain(uid: number): string {
|
||||
/** Deterministic location of the managed LaunchAgent plist. Needed because
|
||||
* after `bootout` launchd no longer reports a path, yet `start` must be able to
|
||||
* bootstrap the very same service back. */
|
||||
export function defaultLaunchAgentPath(home: string, serviceId: string): string {
|
||||
export function defaultLaunchAgentPath(
|
||||
home: string,
|
||||
serviceId: string,
|
||||
): string {
|
||||
return path.posix.join(home, "Library", "LaunchAgents", `${serviceId}.plist`);
|
||||
}
|
||||
|
||||
@@ -162,7 +165,10 @@ export async function stopManagedService(options: {
|
||||
? (["launchctl", "bootout", domain, before.plistPath] as const)
|
||||
: (["launchctl", "bootout", `${domain}/${options.serviceId}`] as const);
|
||||
const result = await options.runCaptured([...target]);
|
||||
if (result.code !== 0 && !/could not find service|service not found/i.test(result.stderr)) {
|
||||
if (
|
||||
result.code !== 0 &&
|
||||
!/could not find service|service not found/i.test(result.stderr)
|
||||
) {
|
||||
throw new Error("launchd_bootout_failed");
|
||||
}
|
||||
// "Stopped" must mean the label stays gone, not that we saw it gone once.
|
||||
@@ -295,9 +301,7 @@ export async function runLaunchctlCaptured(
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.once("error", () => reject(new Error("launchd_command_failed")));
|
||||
child.once("close", (code) =>
|
||||
resolve({ code: code ?? 1, stdout, stderr }),
|
||||
);
|
||||
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyShellPath, planShellPath, removeShellPath } from "./shell-path.js";
|
||||
import {
|
||||
applyShellPath,
|
||||
planShellPath,
|
||||
removeShellPath,
|
||||
} from "./shell-path.js";
|
||||
|
||||
async function home(): Promise<string> {
|
||||
return mkdtemp(path.join(tmpdir(), "memhub-shell-path-"));
|
||||
|
||||
@@ -72,9 +72,7 @@ function blockFor(binDirectory: string): string {
|
||||
* original file byte-for-byte regardless of the original's trailing newline. */
|
||||
function ownedBlock(existing: string | null, binDirectory: string): string {
|
||||
const separator =
|
||||
existing === null || existing === "" || existing.endsWith("\n")
|
||||
? ""
|
||||
: "\n";
|
||||
existing === null || existing === "" || existing.endsWith("\n") ? "" : "\n";
|
||||
return `${separator}${blockFor(binDirectory)}`;
|
||||
}
|
||||
|
||||
@@ -104,10 +102,7 @@ export async function planShellPath(options: {
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
const blocked = (
|
||||
reason: string,
|
||||
target: string,
|
||||
): ShellPathPlan => ({
|
||||
const blocked = (reason: string, target: string): ShellPathPlan => ({
|
||||
schema_version: 1,
|
||||
status: "blocked",
|
||||
blocked_reason: reason,
|
||||
@@ -229,11 +224,12 @@ async function writeProfileAtomically(
|
||||
// A fresh `--home` may not exist yet; create it (owner-only) before writing
|
||||
// the temp profile, otherwise the write fails with a raw ENOENT. Surface a
|
||||
// structured code for any other failure.
|
||||
await mkdir(path.dirname(profilePath), { recursive: true, mode: 0o700 }).catch(
|
||||
() => {
|
||||
throw new Error("shell_path_home_unwritable");
|
||||
},
|
||||
);
|
||||
await mkdir(path.dirname(profilePath), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
}).catch(() => {
|
||||
throw new Error("shell_path_home_unwritable");
|
||||
});
|
||||
const temporary = `${profilePath}.memhub.${process.pid}.tmp`;
|
||||
try {
|
||||
await writeFile(temporary, content, { encoding: "utf8", mode: 0o644 });
|
||||
|
||||
@@ -468,7 +468,9 @@ describe("CLI-19 progress and issue reads", () => {
|
||||
);
|
||||
expect(output).toContain("[15:04:05.067] progress #1");
|
||||
expect(output).toContain("[15:04:05.067] progress #2");
|
||||
expect(output.match(/\x1b\[2J\x1b\[H/g)).toHaveLength(2);
|
||||
expect(output.match(new RegExp("\\x1b\\[2J\\x1b\\[H", "g"))).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps non-terminal watch output append-only", async () => {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import type {
|
||||
CoreOperations,
|
||||
OperationOutcome,
|
||||
} from "./core-operations.js";
|
||||
import type { CoreOperations, OperationOutcome } from "./core-operations.js";
|
||||
import {
|
||||
renderSourceSyncText,
|
||||
setSourceSync,
|
||||
@@ -320,4 +317,3 @@ describe("setSourceSync", () => {
|
||||
expect(renderSourceSyncText(invalid)).toBe("未变更:invalid_source_id\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
SourceSyncInputError,
|
||||
buildSourceSyncRequest,
|
||||
hasSourceSyncTarget,
|
||||
SourceSyncInputError,
|
||||
} from "@one-memory/operations";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
@@ -36,7 +36,10 @@ async function current(
|
||||
reader: CoreReader,
|
||||
sourceId: string,
|
||||
): Promise<
|
||||
| { state: "ok"; source: NonNullable<ReturnType<typeof validateSources>>[number] }
|
||||
| {
|
||||
state: "ok";
|
||||
source: NonNullable<ReturnType<typeof validateSources>>[number];
|
||||
}
|
||||
| { state: "invalid" | "unknown"; code: string }
|
||||
> {
|
||||
const outcome = await reader<unknown>("/api/v1/sources");
|
||||
|
||||
@@ -30,6 +30,8 @@ describe("status default output", () => {
|
||||
expect(text).not.toContain("[object Object]");
|
||||
expect(text).toContain("卡住 类型 整理对话/原因 等待模型/数量 2");
|
||||
expect(text).not.toMatch(/background_verified|additions_only|error_codes=/);
|
||||
expect(text).not.toMatch(/provider_kind|has_api_key|credential_state|observed_at/);
|
||||
expect(text).not.toMatch(
|
||||
/provider_kind|has_api_key|credential_state|observed_at/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -85,12 +85,16 @@ describe("memhub ui command wiring", () => {
|
||||
const output = capture();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(["ui", "--json"], {}, {
|
||||
openBrowser: async (url) => {
|
||||
opened.push(url);
|
||||
return true;
|
||||
code = await run(
|
||||
["ui", "--json"],
|
||||
{},
|
||||
{
|
||||
openBrowser: async (url) => {
|
||||
opened.push(url);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
@@ -102,7 +106,9 @@ describe("memhub ui command wiring", () => {
|
||||
state: "opened",
|
||||
url: "http://127.0.0.1:8765/ui/login?code=one-time-code",
|
||||
});
|
||||
expect(opened).toEqual(["http://127.0.0.1:8765/ui/login?code=one-time-code"]);
|
||||
expect(opened).toEqual([
|
||||
"http://127.0.0.1:8765/ui/login?code=one-time-code",
|
||||
]);
|
||||
// The bearer token was sent to Core in the request header, never in output.
|
||||
expect(output.value()).not.toMatch(/synthetic-runtime-token/);
|
||||
});
|
||||
@@ -159,7 +165,11 @@ describe("memhub ui command wiring", () => {
|
||||
const output = capture();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(["ui", "--json"], {}, { openBrowser: async () => false });
|
||||
code = await run(
|
||||
["ui", "--json"],
|
||||
{},
|
||||
{ openBrowser: async () => false },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
@@ -185,7 +195,11 @@ describe("memhub ui command wiring", () => {
|
||||
const output = capture();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(["ui", "--json"], {}, { openBrowser: async () => true });
|
||||
code = await run(
|
||||
["ui", "--json"],
|
||||
{},
|
||||
{ openBrowser: async () => true },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { parseUiSessionExchange } from "./ui-session.js";
|
||||
describe("ui session exchange over HTTP", () => {
|
||||
let server: Server;
|
||||
let port: number;
|
||||
const seen: Array<{ authorization?: string; method?: string; url?: string }> = [];
|
||||
const seen: Array<{ authorization?: string; method?: string; url?: string }> =
|
||||
[];
|
||||
let status = 200;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
||||
@@ -45,11 +45,31 @@ describe("memhub ui session wiring", () => {
|
||||
for (const doc of [
|
||||
null,
|
||||
{},
|
||||
{ schema_version: 2, exchange_code: "x", expires_in_seconds: 60, url: "http://127.0.0.1:1/ui/login?code=x" },
|
||||
{
|
||||
schema_version: 2,
|
||||
exchange_code: "x",
|
||||
expires_in_seconds: 60,
|
||||
url: "http://127.0.0.1:1/ui/login?code=x",
|
||||
},
|
||||
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 60 },
|
||||
{ schema_version: 1, exchange_code: "", expires_in_seconds: 60, url: "http://127.0.0.1:1/ui/login?code=x" },
|
||||
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 0, url: "http://127.0.0.1:1/ui/login?code=x" },
|
||||
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 60, url: "http://127.0.0.1:1/" },
|
||||
{
|
||||
schema_version: 1,
|
||||
exchange_code: "",
|
||||
expires_in_seconds: 60,
|
||||
url: "http://127.0.0.1:1/ui/login?code=x",
|
||||
},
|
||||
{
|
||||
schema_version: 1,
|
||||
exchange_code: "x",
|
||||
expires_in_seconds: 0,
|
||||
url: "http://127.0.0.1:1/ui/login?code=x",
|
||||
},
|
||||
{
|
||||
schema_version: 1,
|
||||
exchange_code: "x",
|
||||
expires_in_seconds: 60,
|
||||
url: "http://127.0.0.1:1/",
|
||||
},
|
||||
]) {
|
||||
expect(parseUiSessionExchange(doc)).toBeNull();
|
||||
}
|
||||
@@ -59,9 +79,18 @@ describe("memhub ui session wiring", () => {
|
||||
expect(uiSessionFailure("ui_web_assets_unavailable")).toMatchObject({
|
||||
code: "ui_web_assets_unavailable",
|
||||
});
|
||||
expect(uiExitCode({ state: "opened", url: "http://127.0.0.1:1/ui/login?code=x" })).toBe(0);
|
||||
expect(uiExitCode({ state: "blocked", code: "ui_session_contract_unavailable" })).toBe(4);
|
||||
expect(uiExitCode({ state: "failed", code: "ui_session_code_invalid" })).toBe(1);
|
||||
expect(
|
||||
uiExitCode({
|
||||
state: "opened",
|
||||
url: "http://127.0.0.1:1/ui/login?code=x",
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
uiExitCode({ state: "blocked", code: "ui_session_contract_unavailable" }),
|
||||
).toBe(4);
|
||||
expect(
|
||||
uiExitCode({ state: "failed", code: "ui_session_code_invalid" }),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("uses a platform-appropriate opener and reports missing openers", async () => {
|
||||
|
||||
@@ -42,7 +42,12 @@ function isLoopbackHttpUrl(value: string): boolean {
|
||||
}
|
||||
if (parsed.protocol !== "http:") return false;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
|
||||
return (
|
||||
host === "localhost" ||
|
||||
host === "127.0.0.1" ||
|
||||
host === "[::1]" ||
|
||||
host === "::1"
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate the Core exchange document; return null when it does not match the
|
||||
|
||||
@@ -80,7 +80,13 @@ describe("calls clear", () => {
|
||||
});
|
||||
|
||||
it("previews without contacting Core", async () => {
|
||||
const result = await capturedRun(["calls", "clear", "--event", EVENT, "--json"]);
|
||||
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");
|
||||
@@ -341,7 +347,10 @@ describe("notes / extraction / graph / manual extract", () => {
|
||||
"--json",
|
||||
]);
|
||||
expect(applied.code).toBe(0);
|
||||
const body = transport.post.mock.calls.at(-1)?.[1] as Record<string, unknown>;
|
||||
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");
|
||||
|
||||
@@ -10,18 +10,17 @@
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
MemoryNoteInputError,
|
||||
buildMemoryNoteSaveRequest,
|
||||
parseMemoryNoteReceipt,
|
||||
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 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;
|
||||
@@ -117,7 +116,10 @@ function digestOf(value: unknown): string {
|
||||
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: "extraction_window_set" }>,
|
||||
"digest"
|
||||
>
|
||||
| Omit<Extract<ExtendedWritePlan, { kind: "graph_width_decide" }>, "digest">
|
||||
| Omit<Extract<ExtendedWritePlan, { kind: "manual_extract" }>, "digest">;
|
||||
|
||||
@@ -148,7 +150,10 @@ function committed(
|
||||
};
|
||||
}
|
||||
|
||||
function rejected(operationId: string | undefined, code: string): ExtendedWriteOutcome {
|
||||
function rejected(
|
||||
operationId: string | undefined,
|
||||
code: string,
|
||||
): ExtendedWriteOutcome {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
@@ -158,7 +163,10 @@ function rejected(operationId: string | undefined, code: string): ExtendedWriteO
|
||||
};
|
||||
}
|
||||
|
||||
function unknown(operationId: string | undefined, code: string): ExtendedWriteOutcome {
|
||||
function unknown(
|
||||
operationId: string | undefined,
|
||||
code: string,
|
||||
): ExtendedWriteOutcome {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
@@ -197,7 +205,9 @@ export function validNoteText(text: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function planMemoryNoteProcess(evidenceId: string): ExtendedWriteOutcome {
|
||||
export function planMemoryNoteProcess(
|
||||
evidenceId: string,
|
||||
): ExtendedWriteOutcome {
|
||||
return UUID.test(evidenceId)
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
@@ -313,8 +323,7 @@ export function planGraphWidthDecide(decision: string): ExtendedWriteOutcome {
|
||||
|
||||
export function planManualExtract(evidenceIds: string[]): ExtendedWriteOutcome {
|
||||
const ids = [...new Set(evidenceIds)];
|
||||
return ids.length === evidenceIds.length &&
|
||||
ids.every((id) => UUID.test(id))
|
||||
return ids.length === evidenceIds.length && ids.every((id) => UUID.test(id))
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
kind: "manual_extract",
|
||||
@@ -455,12 +464,12 @@ export function validateExtendedWritePlan(
|
||||
}
|
||||
|
||||
export function extendedWriteExitCode(document: ExtendedWriteOutcome): number {
|
||||
return ["preview", "committed", "recovered"].includes(document.state)
|
||||
? 0
|
||||
: 1;
|
||||
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderExtendedWriteText(document: ExtendedWriteOutcome): string {
|
||||
export function renderExtendedWriteText(
|
||||
document: ExtendedWriteOutcome,
|
||||
): string {
|
||||
if (document.state === "preview") {
|
||||
const lines = ["以下操作尚未执行;加 --confirm 后才会写入:"];
|
||||
const plan = document.plan;
|
||||
|
||||
@@ -61,10 +61,9 @@ describe("UI write execution", () => {
|
||||
}));
|
||||
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,
|
||||
);
|
||||
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],
|
||||
@@ -79,10 +78,9 @@ describe("UI write execution", () => {
|
||||
}));
|
||||
const plan = planOf(planCallEventsClearAll());
|
||||
if (plan.kind !== "call_events_clear_all") throw new Error("kind");
|
||||
await executeCallEventsClearAll(
|
||||
plan,
|
||||
{ post } as unknown as CoreOperations,
|
||||
);
|
||||
await executeCallEventsClearAll(plan, {
|
||||
post,
|
||||
} as unknown as CoreOperations);
|
||||
expect(post).toHaveBeenCalledWith("/api/v1/call-events/clear-all", {
|
||||
user_confirmed: true,
|
||||
});
|
||||
@@ -107,7 +105,7 @@ describe("UI write execution", () => {
|
||||
committed: true,
|
||||
},
|
||||
}));
|
||||
const del = vi.fn(async <T,>() => ({
|
||||
const del = vi.fn(async <T>() => ({
|
||||
state: "committed" as const,
|
||||
data: {} as T,
|
||||
}));
|
||||
@@ -118,10 +116,9 @@ describe("UI write execution", () => {
|
||||
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 },
|
||||
);
|
||||
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(
|
||||
@@ -133,15 +130,12 @@ describe("UI write execution", () => {
|
||||
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,
|
||||
);
|
||||
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",
|
||||
|
||||
@@ -14,8 +14,7 @@ import type {
|
||||
} 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 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;
|
||||
|
||||
@@ -139,7 +138,11 @@ export function planCallEventsClear(
|
||||
) {
|
||||
return invalid("call_event_ids_invalid");
|
||||
}
|
||||
return preview({ schema_version: 1, kind: "call_events_clear", event_ids: [...eventIds] });
|
||||
return preview({
|
||||
schema_version: 1,
|
||||
kind: "call_events_clear",
|
||||
event_ids: [...eventIds],
|
||||
});
|
||||
}
|
||||
|
||||
export function planCallEventsClearAll(): UiWriteOutcome {
|
||||
@@ -224,11 +227,7 @@ export async function readImportFileUpload(
|
||||
}
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (
|
||||
!stats.isFile() ||
|
||||
stats.nlink !== 1 ||
|
||||
stats.size > MAX_UPLOAD_BYTES
|
||||
) {
|
||||
if (!stats.isFile() || stats.nlink !== 1 || stats.size > MAX_UPLOAD_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.alloc(Number(stats.size));
|
||||
@@ -273,9 +272,7 @@ function fileUploadOperationId(
|
||||
checksum: string,
|
||||
): string {
|
||||
const hex = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([filename, inputKind, checksum]),
|
||||
)
|
||||
.update(JSON.stringify([filename, inputKind, checksum]))
|
||||
.digest("hex");
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
@@ -325,8 +322,7 @@ export function validateUiWritePlan(value: unknown): UiWritePlan | null {
|
||||
UUID.test(candidate.operation_id) &&
|
||||
typeof candidate.path === "string" &&
|
||||
typeof candidate.filename === "string" &&
|
||||
candidate.filename ===
|
||||
candidate.path.split(/[/\\]/).pop() &&
|
||||
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],
|
||||
@@ -364,9 +360,7 @@ export async function executeCallEventsClear(
|
||||
event_ids: plan.event_ids,
|
||||
user_confirmed: true,
|
||||
});
|
||||
return outcome(result, plan.event_ids.join(","), [
|
||||
"removed",
|
||||
]);
|
||||
return outcome(result, plan.event_ids.join(","), ["removed"]);
|
||||
}
|
||||
|
||||
export async function executeCallEventsClearAll(
|
||||
@@ -543,7 +537,9 @@ function outcome(
|
||||
return committed(operationId, {});
|
||||
}
|
||||
const data =
|
||||
result.data !== null && typeof result.data === "object" && !Array.isArray(result.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))) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
import { createRequire } from "node:module";
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { resolveBuildVersion } = require("../../scripts/release-version.cjs") as {
|
||||
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
|
||||
};
|
||||
const { resolveBuildVersion } =
|
||||
require("../../scripts/release-version.cjs") as {
|
||||
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
|
||||
};
|
||||
const releaseVersion = resolveBuildVersion(process.env);
|
||||
|
||||
export default defineConfig({
|
||||
@@ -18,7 +19,9 @@ export default defineConfig({
|
||||
__ONE_MEMORY_BUILD_NUMBER__: JSON.stringify(
|
||||
process.env.ONE_MEMORY_BUILD_NUMBER ?? "unknown",
|
||||
),
|
||||
__ONE_MEMORY_COMMIT__: JSON.stringify(process.env.ONE_MEMORY_COMMIT ?? "unknown"),
|
||||
__ONE_MEMORY_COMMIT__: JSON.stringify(
|
||||
process.env.ONE_MEMORY_COMMIT ?? "unknown",
|
||||
),
|
||||
__MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(
|
||||
process.env.MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE ?? "",
|
||||
),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const version = JSON.parse(readFileSync(new URL("package.json", import.meta.url), "utf8"))
|
||||
.version as string;
|
||||
const version = JSON.parse(
|
||||
readFileSync(new URL("package.json", import.meta.url), "utf8"),
|
||||
).version as string;
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
| `test_archive_extract.py` | `contract` | Unpacking an archive a stranger sent, onto this user's disk. |
|
||||
| `test_ask_workspace_path.py` | `contract` | Ask must honour the same workspace_path expansion recall uses. |
|
||||
| `test_async_bridge.py` | `contract` | 覆盖 reader rejects parallel workers that could close during next、cancelling a read stops the worker before the batch finishes、starting a read does not stop the app answering 等 10 个场景。 |
|
||||
| `test_async_dispatch_checks.py` | `contract` | 覆盖 quiet arrival guard is off loop and 5000 events coalesce、live preparation cancel joins thread without late dispatch、quiet preparation rechecks permission before credentials or durable work 等 6 个场景。 |
|
||||
| `test_async_dispatch_checks.py` | `contract` | 覆盖 cancelling quiet timer does not start any permission reader、dispatch check does not queue work after shutdown、dispatch permission check denies before queue after classification 等 6 个场景。 |
|
||||
| `test_auth.py` | `integration` | 覆盖 local api requires bearer token、browser origin is checked in addition to token 场景。 |
|
||||
| `test_backfill_acceptance_lifecycle.py` | `integration` | A processing acknowledgement belongs to one consent revision and live worker. |
|
||||
| `test_backfill_acceptance_lifecycle.py` | `contract` | A processing acknowledgement belongs to one consent revision and live worker. |
|
||||
| `test_backfill_conversation_isolation.py` | `contract` | One stale conversation cannot invalidate an entire backfill scan. |
|
||||
| `test_background_work.py` | `contract` | 覆盖 a request does not wait behind a catch up、bulk work is kept to a few threads at a time、the pool outlives one hand off 等 4 个场景。 |
|
||||
| `test_backup_destroy_restore_reconciliation.py` | `contract` | Backup -> destroy -> restore, reconciled row for row. |
|
||||
@@ -25,7 +25,6 @@
|
||||
| `test_bug01_workbuddy_root.py` | `contract` | 3925a kernel item 2 (BUG-01, backend half): WorkBuddy import root. |
|
||||
| `test_bug03_identity_key.py` | `contract` | BUG-03: the hook lane and the file scan must share one identity for one message. |
|
||||
| `test_bug04_thinking_strip.py` | `contract` | BUG-04: Cursor thinking text must not be stored as the assistant's answer. |
|
||||
| `test_bulk_import_experience.py` | `contract` | A-6:大数据量导入的自动检查。 |
|
||||
| `test_c1_append_per_source.py` | `contract` | Append, per source, including the SQLite-backed ones. |
|
||||
| `test_c1_attribution_per_source.py` | `contract` | Attribution per source, asserted on stored Evidence, not just on parsed events. |
|
||||
| `test_c1_conversation_identity_scope.py` | `contract` | Same-named source sessions must stay separate through the read path. |
|
||||
@@ -66,33 +65,23 @@
|
||||
| `test_cli_restore.py` | `contract` | 覆盖 restore requires a source、restore refuses while the runtime is running、restore previews without confirmation 等 4 个场景。 |
|
||||
| `test_codex_header_provenance.py` | `contract` | 覆盖 header workspace versions and native ids reach file origin、new adapter rereads header after a real checkpoint、machine and unknown session sources do not establish human or agent sender 等 14 个场景。 |
|
||||
| `test_compact_model_content.py` | `contract` | Server-held previews preserve exact grants without round-tripping all refs. |
|
||||
| `test_concurrent_import_processing.py` | `integration` | Four real source formats/Core scheduling, with a blocked failing model boundary. |
|
||||
| `test_connection_lifetime.py` | `integration` | Every SQLite connection a request opens must be closed when the request ends. |
|
||||
| `test_context_access.py` | `contract` | 覆盖 memory only never reads raw authority、raw permission does not grant memory and removes revoked sources、raw requires all apps and source 等 13 个场景。 |
|
||||
| `test_context_api.py` | `integration` | 覆盖 no model local query returns real raw and governed items、existing single lane authorization never runs denied reader、authentication and origin remain required 等 14 个场景。 |
|
||||
| `test_context_auto_recall.py` | `contract` | 覆盖 needed history queries trigger without excluding recent turns、ordinary messages do not trigger、auto recall respects disable permission and satisfied state 等 7 个场景。 |
|
||||
| `test_context_endpoint_guard.py` | `contract` | 覆盖 composite route accepts existing read capabilities for later body validation、other capabilities do not enable composite read、composite read does not open other routes 等 5 个场景。 |
|
||||
| `test_context_expand.py` | `contract` | 覆盖 signed expansion returns only bounded same conversation、expansion uses source order not database insertion order、reference is bound to runtime host scope and revision 等 11 个场景。 |
|
||||
| `test_context_feedback.py` | `contract` | 覆盖 explicit feedback contains only offline label and versioned refs、click copy injection and assistant claims are not feedback、feedback cannot be unversioned duplicated or retained forever by omission 等 6 个场景。 |
|
||||
| `test_context_host_artifact.py` | `unit` | 覆盖 each priority host gets an independent artifact、host deadline drops content but preserves source coverage receipt、host character budget omits whole group and never truncates a citation 等 12 个场景。 |
|
||||
| `test_context_hy_primary.py` | `contract` | MemHub_context memory lane: HY decides the order, keywords only when HY does not answer. |
|
||||
| `test_context_memory.py` | `contract` | 覆盖 permissions precede candidate limit and do not leak counts、scope user status and active source isolation、denied scope does not open database 等 60 个场景。 |
|
||||
| `test_context_memory_scale_benchmark.py` | `contract` | 覆盖 memory scale probe covers the required layers and target、memory scale rows preserve governed memory schema shape、memory scale builds the candidate index before measuring 等 9 个场景。 |
|
||||
| `test_context_mutations.py` | `contract` | 覆盖 raw candidate never survives update delete or revoke、memory candidate never survives update delete revoke or lifecycle、denied memory never consumes candidate limit before return 等 5 个场景。 |
|
||||
| `test_context_query_planner.py` | `contract` | 覆盖 local mode performs zero authorization and network calls、authorization precedes exactly one audited transport call、denied or expired budget never calls transport 等 6 个场景。 |
|
||||
| `test_context_raw.py` | `contract` | 覆盖 empty authority and unrequested path do not open database、permissions precede candidate limit and no model needed、explicit source filter never broadens authority 等 37 个场景。 |
|
||||
| `test_context_raw_scale_benchmark.py` | `performance` | 覆盖 raw scale probe uses fts and has a nonzero positive control、object scale probe has exact positive control and plan 场景。 |
|
||||
| `test_context_resume.py` | `contract` | 覆盖 resume packet keeps literal sources and missing fields、agent report and tool output are not system acceptance、only trusted receipt identity marks system acceptance 等 9 个场景。 |
|
||||
| `test_context_raw_scale_benchmark.py` | `contract` | 覆盖 object scale probe has exact positive control and plan、raw scale probe uses fts and has a nonzero positive control 场景。 |
|
||||
| `test_context_search.py` | `contract` | 覆盖 authorization and requested paths never invoke other lane、empty partial zero unavailable and safe failures、parallel common deadline and finalizers wait for both reads 等 24 个场景。 |
|
||||
| `test_context_search_both_paths.py` | `integration` | The objective names 'recall' and requires BOTH paths: raw and memory. |
|
||||
| `test_context_v2_contract_parity.py` | `unit` | Mechanical parity checks for the shared v2 coverage receipt contract. |
|
||||
| `test_context_v2_coordinator.py` | `contract` | 覆盖 no model path returns raw and memory with exact evidence、semantic failure is not recast as empty、empty lexical with incomplete semantic index is not complete no hit 等 52 个场景。 |
|
||||
| `test_continuous_completed_input_review.py` | `contract` | Independent review of completed-input reuse; real stores and controlled HY only. |
|
||||
| `test_continuous_coordinator.py` | `contract` | 覆盖 restart replays lost callback into same durable task queue、new journal event and source regrant wake replay without legacy request、activation hook and startup recovery start single monitor then stop 等 6 个场景。 |
|
||||
| `test_continuous_outcome_unknown.py` | `contract` | 覆盖 crashed continuous send is not replayed but new event progresses、legacy running recovery remains pending and no continuous table、a timed out non continuous task also becomes unknown 等 8 个场景。 |
|
||||
| `test_continuous_policy.py` | `contract` | 覆盖 explicit policy never upgrades legacy or sends or creates tasks、backlog snapshot over one hundred thousand is compact、legacy backlog receipt remains readable 等 19 个场景。 |
|
||||
| `test_continuous_processing_bridge.py` | `contract` | 覆盖 continuous prepare lease ingest has no legacy receipt or old context、continuous inactive never falls back to legacy、revocation rechecked at runner and publication boundaries 等 17 个场景。 |
|
||||
| `test_continuous_processing_integration.py` | `integration` | Real temporary Core/stores with controlled probe and HY boundaries, never real data. |
|
||||
| `test_continuous_coordinator.py` | `contract` | 覆盖 activation hook and startup recovery start single monitor then stop、failed preparation does not advance observed watermark、new journal event and source regrant wake replay without legacy request 等 6 个场景。 |
|
||||
| `test_continuous_outcome_unknown.py` | `contract` | 覆盖 a timed out continuous task becomes unknown not pending、a timed out non continuous task also becomes unknown、an unsettled continuous send becomes unknown not failed 等 8 个场景。 |
|
||||
| `test_continuous_policy.py` | `contract` | 覆盖 backlog snapshot over one hundred thousand is compact、caller rollback includes policy and backlog、enable idempotence rejects changed payload and superseded operation 等 19 个场景。 |
|
||||
| `test_continuous_processing_bridge.py` | `contract` | 覆盖 bound source regrant refuses old task、continuous inactive never falls back to legacy、continuous prepare lease ingest has no legacy receipt or old context 等 17 个场景。 |
|
||||
| `test_continuous_processing_integration.py` | `contract` | Real temporary Core/stores with controlled probe and HY boundaries, never real data. |
|
||||
| `test_contract_parity.py` | `contract` | Python 的枚举和 TypeScript 里那份手写的联合类型必须一致。 |
|
||||
| `test_conversation_search.py` | `integration` | Searching imported conversations before any generation model is configured. |
|
||||
| `test_conversation_seed_ids.py` | `contract` | 覆盖 seed ids read metadata only without hydrating、seed eligibility keeps person and memory content only、latest activity includes nonseed turns and import time fallback 等 13 个场景。 |
|
||||
@@ -105,9 +94,9 @@
|
||||
| `test_daemon_contract.py` | `unit` | A5-01/A5-03: process ownership, and the three grants kept apart. |
|
||||
| `test_daemon_scheduler.py` | `contract` | A5-02/A5-04: the daemon's scan scheduler and composed health. |
|
||||
| `test_data_safety.py` | `contract` | A5-05: backup usability/rotation, crash recovery, log retention. |
|
||||
| `test_database_contention.py` | `integration` | Writers meet in this one file, and the loser used to lose data. |
|
||||
| `test_database_contention.py` | `contract` | Writers meet in this one file, and the loser used to lose data. |
|
||||
| `test_database_file_permissions.py` | `contract` | 覆盖 a new main database is readable by its owner alone、the wal sidecars are not left world readable、a database from an older build is tightened on open 场景。 |
|
||||
| `test_db_session_utc.py` | `live` | The MySQL session must be pinned to UTC. |
|
||||
| `test_db_session_utc.py` | `contract` | The MySQL session must be pinned to UTC. |
|
||||
| `test_delete_no_conversation_freeze.py` | `contract` | 删掉一条记忆,不得让同一对话之后的新 turn 停止沉淀(RC10)。 |
|
||||
| `test_delivery_board.py` | `contract` | 覆盖 parses only the marked structured record、verified requires evidence with a positive control、rejects unknown dependency 等 5 个场景。 |
|
||||
| `test_derived_echo.py` | `contract` | A host that saves our injection back must not mint a new user decision. |
|
||||
@@ -147,7 +136,7 @@
|
||||
| `test_graph_width_export.py` | `unit` | EXPORT_FIRST has to actually export. |
|
||||
| `test_graph_width_guard.py` | `unit` | Graph width mismatch must ask, never silently archive. |
|
||||
| `test_harness_contract_suite.py` | `contract` | A4-09: one contract, ten harnesses. |
|
||||
| `test_health.py` | `integration` | 覆盖 health reports the single core、core refuses to start a database written by a newer build、core stays ready when legacy policy upgrade cannot read keychain 等 15 个场景。 |
|
||||
| `test_health.py` | `contract` | 覆盖 a backfill that fails says so rather than disappearing、a failed catch up leaves a traceback behind、configured model without content consent does not start backfill 等 15 个场景。 |
|
||||
| `test_history_listing_rotation.py` | `contract` | 覆盖 history listing alternates started and untouched files、history listing drains whichever cohort is longer 场景。 |
|
||||
| `test_home_summary.py` | `integration` | 覆盖 summary counts beyond page limit and excludes ineligible rows、summary revocation excludes whole mixed memory not only citation、latest is bounded and other users revocations do not hide local data 等 6 个场景。 |
|
||||
| `test_home_summary_permissions.py` | `contract` | 覆盖 bad active link excludes whole latest memory、bad inactive link does not hide memory or enter source set、other user memory and grant are isolated 等 10 个场景。 |
|
||||
@@ -158,7 +147,7 @@
|
||||
| `test_hy_coding_lease.py` | `contract` | HY's own coding/working route under the permission lease, on the pinned SDK. |
|
||||
| `test_hy_coding_reply.py` | `contract` | R-07: HY's coding-shaped replies read as "no memories at all". |
|
||||
| `test_hy_lease_responsiveness.py` | `contract` | Blocked lease checks, local failing doubles only; no network or credentials. |
|
||||
| `test_hy_lifecycle_sync.py` | `contract` | HY decides deletions; the ledger mirrors them. |
|
||||
| `test_hy_lifecycle_sync.py` | `contract` | HY decides deletions; the ledger mirrors them. Pinned SDK, scripted HTTP. |
|
||||
| `test_hy_log_privacy.py` | `contract` | HY SDK records are not rewritten by One Memory's logging setup. |
|
||||
| `test_hy_memory_bridge.py` | `contract` | 覆盖 hy client initialization is serial across bridges、native hy clients share one runtime per data directory、ollama profile ignores ambient remote storage and models 等 41 个场景。 |
|
||||
| `test_hy_metrics_lifecycle.py` | `contract` | What the pinned public SDK's MetricsCollector does, and what that costs us. |
|
||||
@@ -178,7 +167,7 @@
|
||||
| `test_hy_state_source.py` | `contract` | RC4:记忆生命周期(有效 / 被取代 / 去重隐藏 / 过期)直接读 HY,产品账本不再逐类镜像。 |
|
||||
| `test_hy_step_timing.py` | `contract` | Where an HY write spends the time that is not a model call. |
|
||||
| `test_hy_step_trace_switch.py` | `contract` | Rule 15: the per-step HY trace is off unless the debug tier asks for it. |
|
||||
| `test_hy_supersede_sync.py` | `contract` | HY decides supersessions; the ledger mirrors them. |
|
||||
| `test_hy_supersede_sync.py` | `contract` | HY decides supersessions; the ledger mirrors them. Pinned SDK, scripted HTTP. |
|
||||
| `test_hy_trial_budget.py` | `contract` | Offline proofs for the real-model acceptance budget (no network, no Keychain). |
|
||||
| `test_hy_turn_input.py` | `contract` | HY gets one turn at a time, exactly as it happened, and picks its own route. |
|
||||
| `test_hy_writeback.py` | `integration` | HY 的结果只有一扇门进治理表,而且每次进门都带着调用开始时的证据快照。 |
|
||||
@@ -190,9 +179,9 @@
|
||||
| `test_import_snapshots.py` | `contract` | Atomic snapshot publication plus frozen manifest, path, mode, and per-file checksum guards. |
|
||||
| `test_import_supervisor.py` | `contract` | 覆盖 supervisor starts all granted sources together、supervisor runs enqueued job in background、harness freeze keeps pending job and thaw runs the same row 等 11 个场景。 |
|
||||
| `test_import_three_scenarios.py` | `contract` | The three import scenarios a user actually meets. |
|
||||
| `test_import_three_scenarios_real.py` | `performance` | The three import scenarios, on a real Core at a real volume. |
|
||||
| `test_import_three_scenarios_real.py` | `contract` | The three import scenarios, on a real Core at a real volume. |
|
||||
| `test_imported_index_background.py` | `contract` | The ordering index is built off the startup path. |
|
||||
| `test_incremental_memory_backfill.py` | `contract` | 覆盖 accept backfill returns after first batch while later history is still read、first task is dispatched before later conversation is prepared、cancellation joins the preparation thread before return 等 18 个场景。 |
|
||||
| `test_incremental_memory_backfill.py` | `contract` | 覆盖 accept backfill returns after first batch while later history is still read、backfill budget yields between rounds and resumes same cursor、backfill holds an unanswered raw turn until its reply arrives 等 18 个场景。 |
|
||||
| `test_ingest_journal.py` | `contract` | 覆盖 baseline and empty initialize are explicit transactional and idempotent、old date new import and revise noop and rollbacks、import metadata changes invalidate without authorizing content 等 12 个场景。 |
|
||||
| `test_input_sanitization.py` | `unit` | 覆盖 redacts cli and assignment secrets without removing context、redacts bearer known tokens urls and private keys、redacts quoted json assignments and basic authorization 等 8 个场景。 |
|
||||
| `test_installer.py` | `contract` | 覆盖 installer create verify and rollback is transactional、installer updates owned file and restores backup、installer refuses unowned or changed target 场景。 |
|
||||
@@ -220,7 +209,7 @@
|
||||
| `test_main_database_fairness.py` | `contract` | Waiting for the one database has to be a queue, not a scramble. |
|
||||
| `test_main_database_isolation.py` | `contract` | What one checkout of the main database can do, and what it leaves for the next. |
|
||||
| `test_managed_restore.py` | `contract` | 覆盖 restore rejects a source outside the managed backup directory、restore rejects a symlinked source、restore rejects a non one memory database 等 9 个场景。 |
|
||||
| `test_manual_memory_via_hy.py` | `integration` | T04: what the person explicitly asks to remember is written through HY's import path. |
|
||||
| `test_manual_memory_via_hy.py` | `contract` | T04: what the person explicitly asks to remember is written through HY's import path. |
|
||||
| `test_manual_transcript_import.py` | `integration` | 覆盖 manual json keeps authors unknown time and exact text、marked text keeps code fences and never invents timestamps、ambiguous partial or invalid manual data never leaves partial import 等 4 个场景。 |
|
||||
| `test_matter_collection.py` | `integration` | 覆盖 hy windows cover every turn including leading and boundary assistants、oversize turn is split without silently dropping content、partial timestamps preserve source sequence 等 8 个场景。 |
|
||||
| `test_matter_discovery.py` | `contract` | 覆盖 synthesis materializes only known verbatim citations、duplicate claims keep all governed refs and distinct states、synthesis cannot echo the original sixty claim list 等 27 个场景。 |
|
||||
@@ -247,7 +236,7 @@
|
||||
| `test_memory_governance_api.py` | `integration` | 覆盖 memory governance api preserves evidence and reports cleanup、memory validation does not echo content 场景。 |
|
||||
| `test_memory_graph_api.py` | `integration` | 覆盖 graph labels where every edge came from、a created memory is on the map with its source links、drawing the map does not stop the app answering 等 12 个场景。 |
|
||||
| `test_memory_language.py` | `contract` | R-03: memory language follows the message, so Kristin's gets written in English. |
|
||||
| `test_memory_library.py` | `performance` | 覆盖 full library is reachable in stable pages、permission filter precedes count search source and page、query filters before expensive freshness 等 26 个场景。 |
|
||||
| `test_memory_library.py` | `contract` | 覆盖 full library is reachable in stable pages、permission filter precedes count search source and page、query filters before expensive freshness 等 26 个场景。 |
|
||||
| `test_memory_library_api.py` | `integration` | 覆盖 auth and external policy reject before reading、invalid parameters are 422 before projection、empty unknown source and query boundary are success not failure 等 12 个场景。 |
|
||||
| `test_memory_mutation_receipts.py` | `integration` | Read-only mutation receipts from real governance history, without model calls. |
|
||||
| `test_memory_no_candidate.py` | `integration` | 新记忆一经创建即有效;「待确认」「已拒绝」只作为旧库迁移的输入存在。 |
|
||||
@@ -288,8 +277,8 @@
|
||||
| `test_model_creation_api.py` | `integration` | Normal protected model creation routes with temporary data and in-memory secrets. |
|
||||
| `test_model_creation_operations.py` | `contract` | 覆盖 lookup without table is read only and legacy create does not initialize、operation commits with profile and version without autoactivation、any replay conflicts before secret put 等 21 个场景。 |
|
||||
| `test_model_creation_validation_boundary.py` | `integration` | Real ASGI validation must precede creation reservation and secret writes. |
|
||||
| `test_model_policy_activation.py` | `contract` | 覆盖 current is read only without policy tables、switch and policy commit together without old activate、policy append failure rolls back active and initialization 等 21 个场景。 |
|
||||
| `test_model_policy_routes.py` | `contract` | 覆盖 get does not initialize and capability defaults false、all routes require auth、factory refuses no auth dependency 等 10 个场景。 |
|
||||
| `test_model_policy_activation.py` | `contract` | 覆盖 activation helper is transactional and restores row factory、backlog scan does not hold the single writer、cloud credential is checked without keychain write 等 21 个场景。 |
|
||||
| `test_model_policy_routes.py` | `contract` | 覆盖 all routes require auth、backlog is required not defaulted、enable and transition safe shape and callback once after commit 等 10 个场景。 |
|
||||
| `test_model_probe.py` | `contract` | 覆盖 probe still measures embedding when structured output fails、probe records the real embedding width on the profile、openai compatible probe checks model and structured output 等 23 个场景。 |
|
||||
| `test_model_probe_input_revision.py` | `contract` | 覆盖 input revision precedes measured width update、concurrent update still rejects width or capability cas、early returns record the actual starting revision 等 6 个场景。 |
|
||||
| `test_model_probe_operations.py` | `integration` | Persisted probe identity, real SQLite/API and synthetic transport only. |
|
||||
@@ -300,7 +289,7 @@
|
||||
| `test_model_profile_queue_handover.py` | `contract` | Queued work when the model changes or its configuration is deleted (D75). |
|
||||
| `test_model_profiles.py` | `contract` | 覆盖 profile uses one model and keeps key out of database、api key updates reuse one stable secret reference、startup cleanup removes only secrets not referenced by current profiles 等 15 个场景。 |
|
||||
| `test_model_request_budget.py` | `contract` | 覆盖 persists failed request reservations without content、parallel leases cannot exceed request limit、unknown or excessive output and body are denied 等 6 个场景。 |
|
||||
| `test_model_request_budget_integration.py` | `integration` | Real HY leaf retries plus durable budget accounting; no network or provider. |
|
||||
| `test_model_request_budget_integration.py` | `contract` | Real HY leaf retries plus durable budget accounting; no network or provider. |
|
||||
| `test_model_scope_lifecycle.py` | `contract` | Bound derived/temporary data without changing consent or original history. |
|
||||
| `test_mysql_lifecycle.py` | `contract` | A1-07: the decisions Runtime makes around the bundled MySQL. |
|
||||
| `test_mysql_platform.py` | `contract` | A1-07: the machine-facing half of managing the bundled MySQL. |
|
||||
@@ -310,7 +299,7 @@
|
||||
| `test_oversized_file_guard.py` | `contract` | One pass over a source file is bounded by a byte budget, not skipped. |
|
||||
| `test_parent_watchdog.py` | `integration` | A Core that outlives its launcher makes the next launch impossible. |
|
||||
| `test_policy_api_wiring.py` | `integration` | 覆盖 global model permission off does not backfill、backfill puts history on write jobs without scheduling memory tasks、source pause skips history until the source is reenabled 场景。 |
|
||||
| `test_policy_operation_lookup.py` | `contract` | 覆盖 missing table returns not found without initializing or reading secrets、current operation uses safe receipt without callback or writes、superseded operation preserves historical receipt not current activation 等 12 个场景。 |
|
||||
| `test_policy_operation_lookup.py` | `contract` | 覆盖 corrupt current row cannot make history or missing operation look safe、current operation uses safe receipt without callback or writes、lookup connection rejects writes and preserves database 等 12 个场景。 |
|
||||
| `test_policy_production_entry.py` | `contract` | 覆盖 production serve passes only supported app options、maintenance serve does not own the runtime 场景。 |
|
||||
| `test_precipitation_concurrency_setting.py` | `contract` | Rule 2/9: precipitation concurrency is configurable, not hardcoded to 1. |
|
||||
| `test_primary_source_adapters.py` | `contract` | 覆盖 pipeline json is host generated even when truncated、workbuddy record parsing never blocks the event loop、cursor store user info envelope is not attributed to the person 等 34 个场景。 |
|
||||
@@ -318,20 +307,18 @@
|
||||
| `test_probe_payment_required.py` | `contract` | 覆盖 http 402 without a useful body is still understood as unpaid、a wrong address is still a missing model not an unpaid one 场景。 |
|
||||
| `test_processing_coordinator.py` | `contract` | 覆盖 monitor is retained and observes config plus tasks 场景。 |
|
||||
| `test_processing_merged_context.py` | `contract` | 覆盖 new chunk omits known memory with changed support but keeps original history 场景。 |
|
||||
| `test_processing_requests.py` | `contract` | 覆盖 receipt is small frozen and persistent、grant deduplication never retriggers or changes profile、concurrent create same grant has one id and profile 等 14 个场景。 |
|
||||
| `test_product_contract_coverage.py` | `contract` | 覆盖 full app contract is part of the existing acceptance manifest、core visual phase has its own contract without erasing full product scope 场景。 |
|
||||
| `test_processing_requests.py` | `contract` | 覆盖 concurrent claim only one snapshot wins、concurrent create same grant has one id and profile、concurrent different profile never reuses another scope 等 14 个场景。 |
|
||||
| `test_quality_acceptance.py` | `contract` | 覆盖 acceptance pack is deterministic and scores personal boundary、acceptance score fails missing personal memory 场景。 |
|
||||
| `test_raw_search_without_model.py` | `integration` | Search imported conversations with no generative model configured at all. |
|
||||
| `test_raw_vector_scale_benchmark.py` | `performance` | 覆盖 vector scale probe uses real 512 dimension filtered search 场景。 |
|
||||
| `test_raw_vector_scale_benchmark.py` | `contract` | 覆盖 vector scale probe uses real 512 dimension filtered search 场景。 |
|
||||
| `test_read_path_freshness_batch.py` | `contract` | The row-by-row read check must not scale its query count with the page. |
|
||||
| `test_reader_app.py` | `integration` | The Reader process stays read-only and never blocks a read on its side effects. |
|
||||
| `test_reader_conversation_hit.py` | `contract` | A search hit carries who wrote the row it matched. |
|
||||
| `test_real_load_metrics.py` | `performance` | Run the real access layer under a production-like combined load. |
|
||||
| `test_real_model_prereq_guards.py` | `contract` | Real-model first-run preconditions must fail closed before any egress. |
|
||||
| `test_real_load_metrics.py` | `contract` | Run the real access layer under a production-like combined load. |
|
||||
| `test_recall.py` | `contract` | 覆盖 recall is confirmed only grounded and scope bound、wildcard scope means every scope just like it does in qa、recall returns empty instead of unrelated fallback 等 10 个场景。 |
|
||||
| `test_recall_during_import_probe_contract.py` | `contract` | Guard the source-level recall-during-import probe's sampling semantics. |
|
||||
| `test_recall_excluded_sources.py` | `contract` | D94: an empty result names the sources this host may not read. |
|
||||
| `test_recall_fusion_live.py` | `live` | RCL-04 against a real HY instance instead of a stand-in. |
|
||||
| `test_recall_fusion_live.py` | `integration` | RCL-04 against a real HY instance instead of a stand-in. |
|
||||
| `test_recall_hy_primary.py` | `integration` | Host recall: HY search decides the order, the ledger only filters. |
|
||||
| `test_recall_library_scale.py` | `contract` | 覆盖 recall reaches a memory that is not among the newest 500、semantic recall reaches a memory outside the newest 500、recall still works just below the boundary 等 5 个场景。 |
|
||||
| `test_recall_log_probe_exclusion.py` | `contract` | A-21:R-10 一旦引入召回日志,「探针不污染」必须自动变红而不是静默失效。 |
|
||||
@@ -343,14 +330,13 @@
|
||||
| `test_recall_scope_off_loop.py` | `contract` | The workspace lookup behind an external recall must not run on the loop. |
|
||||
| `test_recent_first_import.py` | `contract` | Recent-file priority survives restart through a selection-bound frozen copy. |
|
||||
| `test_retrieval_chunking.py` | `contract` | 覆盖 default 384 body 64 overlap and exact source slices、all prefix and special tokens count toward strict 512 budget、real tokenizer covers long chinese middle and tail object locations 等 20 个场景。 |
|
||||
| `test_retrieval_eligibility_benchmark.py` | `performance` | 覆盖 scale probe proves composite authority index changes the plan 场景。 |
|
||||
| `test_retrieval_eligibility_benchmark.py` | `contract` | 覆盖 scale probe proves composite authority index changes the plan 场景。 |
|
||||
| `test_retrieval_index_store.py` | `contract` | 覆盖 constructor and reads do not create database or schema、explicit initialization rolls back and never grants model policy、consumer and jobs commit together 等 19 个场景。 |
|
||||
| `test_retrieval_index_supervisor.py` | `contract` | 覆盖 stop waits for native exit and does not close resources、async cancellation repeatedly waits for native thread、idle no trigger no spin and duplicate start rejected 等 14 个场景。 |
|
||||
| `test_retrieval_index_worker.py` | `contract` | 覆盖 tick indexes complete original and reopen can search、change during native encoding never publishes、cancel after encode does not publish and is reclaimable 等 10 个场景。 |
|
||||
| `test_retrieval_lifespan.py` | `integration` | 覆盖 retrieval defaults off and does not create index、enabling retrieval requires runtime ownership、index enabled serve imports without building an unmanaged app 等 6 个场景。 |
|
||||
| `test_retrieval_mapped_content.py` | `contract` | 覆盖 raw authors keep exact body and do not mutate evidence、raw and author qualification rejects without tokenizing、ambiguous generated and body hygiene are not admitted 等 22 个场景。 |
|
||||
| `test_retrieval_mapped_integration.py` | `contract` | 覆盖 empty materialization rechecks excluded map、completed excluded missing active binding is not empty success、old empty completion is invalidated when a new indexed record arrives 等 24 个场景。 |
|
||||
| `test_retrieval_models.py` | `unit` | RCL v2 wire contracts only; no service, model or database is initialized. |
|
||||
| `test_retrieval_provisioning.py` | `contract` | The index has to come up through the product, not through a test helper. |
|
||||
| `test_retrieval_query_plan.py` | `contract` | Local QueryPlan: extract URL/path objects. |
|
||||
| `test_retrieval_raw_candidates.py` | `contract` | 覆盖 positive only current authorized published chunks are searched、empty authority never reads database or vector、unapproved source never enters vector query 等 15 个场景。 |
|
||||
@@ -384,7 +370,7 @@
|
||||
| `test_secret_store_error_codes.py` | `integration` | 3925a kernel item 3: keychain failure classification and backoff that heals. |
|
||||
| `test_secure_storage.py` | `integration` | 覆盖 isolated keychain service namespace、custom keychain service is refused outside isolated tests、packaged keychain budget covers loaded process startup 等 20 个场景。 |
|
||||
| `test_sedimentation_concurrency.py` | `contract` | Sedimentation runs several harnesses at once, but one harness at a time. |
|
||||
| `test_service_level_pressure_acceptance.py` | `performance` | 覆盖 workbuddy recall remains available during 100k import 场景。 |
|
||||
| `test_service_level_pressure_acceptance.py` | `contract` | 覆盖 workbuddy recall remains available during 100k import 场景。 |
|
||||
| `test_service_levels.py` | `contract` | 覆盖 background never consumes the realtime reservation、realtime is next for the same source after background yields、import history gets one slice after nine realtime admissions 等 8 个场景。 |
|
||||
| `test_session_inbox.py` | `contract` | A session inbox is not a shared mailbox and a pull is not adoption. |
|
||||
| `test_session_lease.py` | `contract` | A session file on disk is not proof that the session is online. |
|
||||
@@ -393,7 +379,7 @@
|
||||
| `test_simplified_model_permission_migrations.py` | `contract` | 覆盖 fresh schema omits removed permission state machines 场景。 |
|
||||
| `test_snapshot_adapter.py` | `contract` | Selection-only binding reopens checksum-verified frozen copies. |
|
||||
| `test_snapshot_pool.py` | `contract` | A whole-directory snapshot must not hold a thread everything else is waiting for. |
|
||||
| `test_soak_probe_effectiveness.py` | `contract` | The soak probe must observe the load it reports on. |
|
||||
| `test_soak_probe_effectiveness.py` | `integration` | The soak probe must observe the load it reports on. |
|
||||
| `test_source_access.py` | `contract` | 覆盖 source root requires explicit user grant、sensitive home directory is rejected without requesting os access、authorized file cannot escape root through a symlink 等 24 个场景。 |
|
||||
| `test_source_candidate_api.py` | `integration` | Candidate location changes must not implicitly grant or import anything. |
|
||||
| `test_source_candidate_connections.py` | `contract` | 覆盖 empty candidate revisions preserve default fingerprint、persisted candidate requires exact revision、connection resolves candidate in transaction and prior replay never regrants 等 7 个场景。 |
|
||||
@@ -420,7 +406,7 @@
|
||||
| `test_source_write_switch.py` | `contract` | A frozen Harness produces no extraction tasks. |
|
||||
| `test_sql_compat.py` | `contract` | The shared compatibility layer, especially 'rowcount'. |
|
||||
| `test_sqlite_semantics_gate.py` | `contract` | D56: prevent SQLite implementation details spreading outside the data layer. |
|
||||
| `test_sqlite_write_contention.py` | `performance` | Measure whether SQLite's single writer actually blocks us. |
|
||||
| `test_sqlite_write_contention.py` | `contract` | Measure whether SQLite's single writer actually blocks us. |
|
||||
| `test_storage_ingest_journal.py` | `contract` | 覆盖 legacy put and reopen never initialize journal、initialized put records final origins create revision and noop、attribution propagation journals old rows as maintenance only 等 11 个场景。 |
|
||||
| `test_store_binding.py` | `contract` | A2: which store is in force, and honest reporting of it. |
|
||||
| `test_store_binding_ratchet.py` | `contract` | D56: ratchet the retired dual-engine 'store_for' seam out of the Core. |
|
||||
@@ -432,7 +418,6 @@
|
||||
| `test_tail_metadata_lane.py` | `contract` | Post-response metadata retries must be bounded and never block the route. |
|
||||
| `test_terminal_payload_reaping.py` | `contract` | Rule 14: dead-end payloads are actually reaped, not just reapable. |
|
||||
| `test_tool_evidence_hy_turns.py` | `contract` | Every adapter's tool records reach HY inside the turn they happened in. |
|
||||
| `test_training_extraction_and_baseline.py` | `contract` | R-17:抽取结果落盘成训练数据,评测基线冻结成可复跑产物。 |
|
||||
| `test_two_level_discovery.py` | `contract` | 3925a kernel item 1: two-level discovery. |
|
||||
| `test_ui_session.py` | `contract` | 覆盖 exchange is single use and creates a session、expired exchange cannot create a session、exchange is bound to the core instance 等 5 个场景。 |
|
||||
| `test_ui_session_api.py` | `integration` | 覆盖 ui session exchange sets httponly strict cookie、ui session requires bearer for exchange、ui session reports missing web assets 场景。 |
|
||||
@@ -448,4 +433,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. |
|
||||
|
||||
共 443 个测试文件。
|
||||
共 428 个测试文件。
|
||||
@@ -14,14 +14,13 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||
EXPECTED_TEST_FILE_COUNT = 443
|
||||
EXPECTED_TEST_FILES_SHA256 = "6d4815ac6a76cfa3f6208672052b785c7562083cf9d122cc9ddc796b8e624d43"
|
||||
EXPECTED_TEST_FILE_COUNT = 428
|
||||
EXPECTED_TEST_FILES_SHA256 = "bd3bf4fc9cda2a8e4427d106283c40d22dad026fd452db954046b6f28d21b896"
|
||||
|
||||
# Pure transformations, validation models, parity checks and state machines.
|
||||
# This list is intentionally small: uncertain files default to contract.
|
||||
UNIT_FILES = frozenset(
|
||||
{
|
||||
"test_context_host_artifact.py",
|
||||
"test_context_v2_contract_parity.py",
|
||||
"test_daemon_contract.py",
|
||||
"test_diagnostic_payload_keys.py",
|
||||
@@ -34,7 +33,6 @@ UNIT_FILES = frozenset(
|
||||
"test_memory_server_config_state.py",
|
||||
"test_memory_server_contracts.py",
|
||||
"test_memory_server_job_state.py",
|
||||
"test_retrieval_models.py",
|
||||
"test_scan_roots.py",
|
||||
"test_scanner_classifier.py",
|
||||
"test_scanner_sessionizer.py",
|
||||
@@ -50,7 +48,6 @@ UNIT_FILES = frozenset(
|
||||
INTEGRATION_FILES = frozenset(
|
||||
{
|
||||
"test_auth.py",
|
||||
"test_backfill_acceptance_lifecycle.py",
|
||||
"test_backup_endpoint.py",
|
||||
"test_backup_restore_api.py",
|
||||
"test_c1_import_via_api_path.py",
|
||||
@@ -58,11 +55,9 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_capture_deadline.py",
|
||||
"test_chat_exports.py",
|
||||
"test_cli_lifecycle.py",
|
||||
"test_concurrent_import_processing.py",
|
||||
"test_connection_lifetime.py",
|
||||
"test_context_api.py",
|
||||
"test_context_search_both_paths.py",
|
||||
"test_continuous_processing_integration.py",
|
||||
"test_conversation_search.py",
|
||||
"test_credential_responsiveness.py",
|
||||
"test_d93_same_process_load.py",
|
||||
@@ -75,7 +70,6 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_file_import_operations.py",
|
||||
"test_four_app_live_ingest.py",
|
||||
"test_graph_width_endpoint.py",
|
||||
"test_health.py",
|
||||
"test_home_summary.py",
|
||||
"test_hy_writeback.py",
|
||||
"test_import_api.py",
|
||||
@@ -85,7 +79,6 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_log_privacy.py",
|
||||
"test_loopback_origin_write.py",
|
||||
"test_main_database_entry.py",
|
||||
"test_manual_memory_via_hy.py",
|
||||
"test_manual_transcript_import.py",
|
||||
"test_matter_collection.py",
|
||||
"test_matter_hy_integration.py",
|
||||
@@ -113,7 +106,6 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_model_probe_operations.py",
|
||||
"test_model_profile_api.py",
|
||||
"test_model_profile_context_reset.py",
|
||||
"test_model_request_budget_integration.py",
|
||||
"test_onboarding_profile.py",
|
||||
"test_parent_watchdog.py",
|
||||
"test_policy_api_wiring.py",
|
||||
|
||||
@@ -1 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="172"><defs><filter id="filter_0" color-interpolation-filters="sRGB" x="0" y="8" width="164" height="164" filterUnits="userSpaceOnUse"><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset dx="0" dy="8"/><feGaussianBlur stdDeviation="9"/><feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.28 0 0 0 0 0.34 0 0 0 0.12 0" result="shadow"/></filter></defs><g filter="url(#filter_0)"><rect fill="#FFF" transform="matrix(1 0 0 1 18 18)" width="128" height="128" rx="28" ry="28"/><rect fill="none" stroke="#FFF" stroke-width="12" transform="matrix(1 0 0 1 46 41)" width="56" height="68" rx="23" ry="23"/><rect fill="none" stroke="#FFF" stroke-width="12" transform="matrix(1 0 0 1 62 55)" width="56" height="68" rx="23" ry="23"/><clipPath id="clip_0"><rect x="18" y="18" width="128" height="128" rx="28" ry="28"/></clipPath><g clip-path="url(#clip_0)"><path fill="#FFF" transform="matrix(1 0 0 1 18 18)" d="M129 28L129 100Q129 112.0121 120.5061 120.5061Q112.0121 129.0001 100 129L28 129Q15.9878 129 7.4939 120.5061Q-1 112.0121 -1 100L-1 28Q-1 15.9878 7.4939 7.4939Q15.9878 -1 28 -1L100 -1Q112.0122 -1 120.5061 7.4939Q129.0001 15.9878 129 28ZM127 28Q127 16.8162 119.0919 8.9081Q111.1838 1 100 1L28 1Q16.8162 1 8.9081 8.9081Q1 16.8162 1 28L1 100Q1 111.1838 8.9081 119.0919Q16.8162 127.0001 28 127L100 127Q111.1837 127 119.0919 119.0919Q127 111.1838 127 100L127 28Z"/></g></g><rect fill="#FFF" transform="matrix(1 0 0 1 18 18)" width="128" height="128" rx="28" ry="28"/><rect fill="none" stroke="#829CAD" stroke-width="12" transform="matrix(1 0 0 1 46 41)" width="56" height="68" rx="23" ry="23"/><rect fill="none" stroke="#4F6373" stroke-width="12" transform="matrix(1 0 0 1 62 55)" width="56" height="68" rx="23" ry="23"/><path fill="#5E7C8C" transform="matrix(1 0 0 1 79 64)" d="M23 0L23 22Q23 26.6757 21.1904 30.9547Q19.4442 35.0835 16.2639 38.2639Q13.0835 41.4442 8.9547 43.1904Q4.6757 45 0 45Q-2.5153 45.0724 -4.2426 43.2426Q-6.0724 41.5153 -6 39Q-6.0724 36.4847 -4.2426 34.7574Q-2.5153 32.9276 0 33Q2.2425 33 4.2806 32.1381Q6.2535 31.3037 7.7786 29.7786Q9.3037 28.2535 10.1381 26.2806Q11 24.2425 11 22L11 0Q10.9276 -2.5153 12.7574 -4.2426Q14.4847 -6.0724 17 -6Q19.5153 -6.0724 21.2426 -4.2426Q23.0724 -2.5153 23 0Z"/><clipPath id="clip_1"><rect x="18" y="18" width="128" height="128" rx="28" ry="28"/></clipPath><g clip-path="url(#clip_1)"><path fill="#FFF" transform="matrix(1 0 0 1 18 18)" d="M129 28L129 100Q129 112.0121 120.5061 120.5061Q112.0121 129.0001 100 129L28 129Q15.9878 129 7.4939 120.5061Q-1 112.0121 -1 100L-1 28Q-1 15.9878 7.4939 7.4939Q15.9878 -1 28 -1L100 -1Q112.0122 -1 120.5061 7.4939Q129.0001 15.9878 129 28ZM127 28Q127 16.8162 119.0919 8.9081Q111.1838 1 100 1L28 1Q16.8162 1 8.9081 8.9081Q1 16.8162 1 28L1 100Q1 111.1838 8.9081 119.0919Q16.8162 127.0001 28 127L100 127Q111.1837 127 119.0919 119.0919Q127 111.1838 127 100L127 28Z"/></g></svg>
|
||||
<?xml version="1.0" encoding="utf-8" ?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="172"><title>MemHub</title><defs><filter id="filter_0" color-interpolation-filters="sRGB" x="0" y="8" width="164" height="164" filterUnits="userSpaceOnUse"><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset dx="0" dy="8"/><feGaussianBlur stdDeviation="9"/><feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.28 0 0 0 0 0.34 0 0 0 0.12 0" result="shadow"/></filter></defs><g filter="url(#filter_0)"><rect fill="#FFF" transform="matrix(1 0 0 1 18 18)" width="128" height="128" rx="28" ry="28"/><rect fill="none" stroke="#FFF" stroke-width="12" transform="matrix(1 0 0 1 46 41)" width="56" height="68" rx="23" ry="23"/><rect fill="none" stroke="#FFF" stroke-width="12" transform="matrix(1 0 0 1 62 55)" width="56" height="68" rx="23" ry="23"/><clipPath id="clip_0"><rect x="18" y="18" width="128" height="128" rx="28" ry="28"/></clipPath><g clip-path="url(#clip_0)"><path fill="#FFF" transform="matrix(1 0 0 1 18 18)" d="M129 28L129 100Q129 112.0121 120.5061 120.5061Q112.0121 129.0001 100 129L28 129Q15.9878 129 7.4939 120.5061Q-1 112.0121 -1 100L-1 28Q-1 15.9878 7.4939 7.4939Q15.9878 -1 28 -1L100 -1Q112.0122 -1 120.5061 7.4939Q129.0001 15.9878 129 28ZM127 28Q127 16.8162 119.0919 8.9081Q111.1838 1 100 1L28 1Q16.8162 1 8.9081 8.9081Q1 16.8162 1 28L1 100Q1 111.1838 8.9081 119.0919Q16.8162 127.0001 28 127L100 127Q111.1837 127 119.0919 119.0919Q127 111.1838 127 100L127 28Z"/></g></g><rect fill="#FFF" transform="matrix(1 0 0 1 18 18)" width="128" height="128" rx="28" ry="28"/><rect fill="none" stroke="#829CAD" stroke-width="12" transform="matrix(1 0 0 1 46 41)" width="56" height="68" rx="23" ry="23"/><rect fill="none" stroke="#4F6373" stroke-width="12" transform="matrix(1 0 0 1 62 55)" width="56" height="68" rx="23" ry="23"/><path fill="#5E7C8C" transform="matrix(1 0 0 1 79 64)" d="M23 0L23 22Q23 26.6757 21.1904 30.9547Q19.4442 35.0835 16.2639 38.2639Q13.0835 41.4442 8.9547 43.1904Q4.6757 45 0 45Q-2.5153 45.0724 -4.2426 43.2426Q-6.0724 41.5153 -6 39Q-6.0724 36.4847 -4.2426 34.7574Q-2.5153 32.9276 0 33Q2.2425 33 4.2806 32.1381Q6.2535 31.3037 7.7786 29.7786Q9.3037 28.2535 10.1381 26.2806Q11 24.2425 11 22L11 0Q10.9276 -2.5153 12.7574 -4.2426Q14.4847 -6.0724 17 -6Q19.5153 -6.0724 21.2426 -4.2426Q23.0724 -2.5153 23 0Z"/><clipPath id="clip_1"><rect x="18" y="18" width="128" height="128" rx="28" ry="28"/></clipPath><g clip-path="url(#clip_1)"><path fill="#FFF" transform="matrix(1 0 0 1 18 18)" d="M129 28L129 100Q129 112.0121 120.5061 120.5061Q112.0121 129.0001 100 129L28 129Q15.9878 129 7.4939 120.5061Q-1 112.0121 -1 100L-1 28Q-1 15.9878 7.4939 7.4939Q15.9878 -1 28 -1L100 -1Q112.0122 -1 120.5061 7.4939Q129.0001 15.9878 129 28ZM127 28Q127 16.8162 119.0919 8.9081Q111.1838 1 100 1L28 1Q16.8162 1 8.9081 8.9081Q1 16.8162 1 28L1 100Q1 111.1838 8.9081 119.0919Q16.8162 127.0001 28 127L100 127Q111.1837 127 119.0919 119.0919Q127 111.1838 127 100L127 28Z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -77,7 +77,9 @@ function releaseVersion(context) {
|
||||
const packaged = context.packager?.appInfo?.version?.trim();
|
||||
const version = supplied || packaged;
|
||||
return typeof version === "string" &&
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version)
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(
|
||||
version,
|
||||
)
|
||||
? version
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -493,17 +493,17 @@ function publicItem(
|
||||
receipt: !receipt
|
||||
? null
|
||||
: {
|
||||
kind: receipt.kind,
|
||||
hostId: receipt.host.hostId,
|
||||
installationId: receipt.host.installationId as string,
|
||||
permissionRevision: receipt.host.permissionRevision,
|
||||
...(receipt.kind === "readGrant"
|
||||
? {
|
||||
allowedTools: [...receipt.allowedTools],
|
||||
allowedScopes: [...receipt.allowedScopes],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
kind: receipt.kind,
|
||||
hostId: receipt.host.hostId,
|
||||
installationId: receipt.host.installationId as string,
|
||||
permissionRevision: receipt.host.permissionRevision,
|
||||
...(receipt.kind === "readGrant"
|
||||
? {
|
||||
allowedTools: [...receipt.allowedTools],
|
||||
allowedScopes: [...receipt.allowedScopes],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
function selectionInput(input: unknown): ConnectionSetupSelection {
|
||||
|
||||
@@ -4,10 +4,7 @@ import { desktopVersionOutput } from "./desktop-version";
|
||||
describe("Desktop version probe", () => {
|
||||
it("returns packaged SemVer for the product-private probe", () => {
|
||||
expect(
|
||||
desktopVersionOutput(
|
||||
["MemHub", "--memhub-version"],
|
||||
"0.1.0-internal.2",
|
||||
),
|
||||
desktopVersionOutput(["MemHub", "--memhub-version"], "0.1.0-internal.2"),
|
||||
).toBe("MemHub 0.1.0-internal.2\n");
|
||||
});
|
||||
|
||||
|
||||
@@ -436,7 +436,9 @@ describe("display preference preload", () => {
|
||||
it("preload exposes the explicit background restart action", async () => {
|
||||
const api = await preload();
|
||||
fake.invoke.mockResolvedValue({ state: "restarted" });
|
||||
await expect(api.restartBackground()).resolves.toEqual({ state: "restarted" });
|
||||
await expect(api.restartBackground()).resolves.toEqual({
|
||||
state: "restarted",
|
||||
});
|
||||
expect(fake.invoke).toHaveBeenCalledExactlyOnceWith(
|
||||
"one-memory:restart-background",
|
||||
);
|
||||
|
||||
@@ -8,7 +8,9 @@ import { installEarlyStartupErrorHandler } from "./early-startup-errors";
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true })));
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("early desktop startup errors", () => {
|
||||
@@ -31,7 +33,9 @@ describe("early desktop startup errors", () => {
|
||||
});
|
||||
|
||||
it("writes stderr and diagnostics before showing the fatal dialog", async () => {
|
||||
const dataDir = await mkdtemp(path.join(os.tmpdir(), "desktop-early-error-"));
|
||||
const dataDir = await mkdtemp(
|
||||
path.join(os.tmpdir(), "desktop-early-error-"),
|
||||
);
|
||||
roots.push(dataDir);
|
||||
const processEvents = new EventEmitter();
|
||||
const order: string[] = [];
|
||||
@@ -47,7 +51,8 @@ describe("early desktop startup errors", () => {
|
||||
processEvents.emit(
|
||||
"uncaughtException",
|
||||
Object.assign(new Error("desktop_test_real_home_mismatch"), {
|
||||
stack: "Error: desktop_test_real_home_mismatch\n at boot (apps/desktop/src/main.ts:80:1)",
|
||||
stack:
|
||||
"Error: desktop_test_real_home_mismatch\n at boot (apps/desktop/src/main.ts:80:1)",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ export function installEarlyStartupErrorHandler(
|
||||
if (handled) return;
|
||||
handled = true;
|
||||
const fields = diagnosticError(error);
|
||||
const message = error instanceof Error ? error.message : "desktop_startup_failed";
|
||||
const message =
|
||||
error instanceof Error ? error.message : "desktop_startup_failed";
|
||||
const errorCode = SAFE_ERROR_CODE.test(message)
|
||||
? message
|
||||
: "desktop_startup_failed";
|
||||
|
||||
@@ -65,29 +65,29 @@ describe("formal package test isolation", () => {
|
||||
it.skipIf(process.platform !== "darwin")(
|
||||
"accepts the macOS /tmp alias for an existing /private/tmp test home",
|
||||
() => {
|
||||
const root = mkdtempSync("/private/tmp/one-memory-desktop-alias.");
|
||||
const home = path.join(root, "home");
|
||||
const dataDir = path.join(root, "data");
|
||||
const hostHome = path.join(root, "host-home");
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
mkdirSync(hostHome, { recursive: true });
|
||||
const root = mkdtempSync("/private/tmp/one-memory-desktop-alias.");
|
||||
const home = path.join(root, "home");
|
||||
const dataDir = path.join(root, "data");
|
||||
const hostHome = path.join(root, "host-home");
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
mkdirSync(hostHome, { recursive: true });
|
||||
|
||||
expect(
|
||||
resolveDesktopTestIsolation(
|
||||
{
|
||||
ONE_MEMORY_TEST_ISOLATED: "1",
|
||||
ONE_MEMORY_TEST_REAL_HOME: "/Users/example",
|
||||
ONE_MEMORY_DESKTOP_TEST_HOME: home,
|
||||
ONE_MEMORY_DATA_DIR: dataDir,
|
||||
ONE_MEMORY_HOST_HOME: hostHome,
|
||||
ONE_MEMORY_CORE_PORT: "27791",
|
||||
ONE_MEMORY_RUNTIME_PORT: "27791",
|
||||
ONE_MEMORY_BROKER_PORT: "37791",
|
||||
},
|
||||
home.replace(/^\/private\/tmp\//, "/tmp/"),
|
||||
),
|
||||
).toMatchObject({ home });
|
||||
expect(
|
||||
resolveDesktopTestIsolation(
|
||||
{
|
||||
ONE_MEMORY_TEST_ISOLATED: "1",
|
||||
ONE_MEMORY_TEST_REAL_HOME: "/Users/example",
|
||||
ONE_MEMORY_DESKTOP_TEST_HOME: home,
|
||||
ONE_MEMORY_DATA_DIR: dataDir,
|
||||
ONE_MEMORY_HOST_HOME: hostHome,
|
||||
ONE_MEMORY_CORE_PORT: "27791",
|
||||
ONE_MEMORY_RUNTIME_PORT: "27791",
|
||||
ONE_MEMORY_BROKER_PORT: "37791",
|
||||
},
|
||||
home.replace(/^\/private\/tmp\//, "/tmp/"),
|
||||
),
|
||||
).toMatchObject({ home });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ it.skipIf(process.platform !== "darwin")(
|
||||
process: { env: {} },
|
||||
require(name: string) {
|
||||
if (name === "node:crypto")
|
||||
return { randomUUID: () => "00000000-0000-4000-8000-000000000000" };
|
||||
return {
|
||||
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
||||
};
|
||||
if (name === "node:path") return path;
|
||||
if (name === "node:child_process")
|
||||
return {
|
||||
|
||||
@@ -191,7 +191,9 @@ describe("packaged macOS permission metadata", () => {
|
||||
expect(fixture.plist.get("CFBundleShortVersionString")).toBe("1.2.3");
|
||||
expect(fixture.plist.get("CFBundleVersion")).toBe("3903");
|
||||
expect(fixture.plist.get("LSEnvironment:ONE_MEMORY_VERSION")).toBe("1.2.3");
|
||||
expect(fixture.plist.get("LSEnvironment:ONE_MEMORY_BUILD_NUMBER")).toBe("3903");
|
||||
expect(fixture.plist.get("LSEnvironment:ONE_MEMORY_BUILD_NUMBER")).toBe(
|
||||
"3903",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes inherited audio capture and existing unused keys before signing", async () => {
|
||||
@@ -209,10 +211,7 @@ describe("packaged macOS permission metadata", () => {
|
||||
// Production clears any stale build marker before removing unused usage
|
||||
// strings and signing. No LaunchAgent plist is written.
|
||||
expect(fixture.execFileSync.mock.calls).toEqual([
|
||||
[
|
||||
"/usr/libexec/PlistBuddy",
|
||||
["-c", "Add :LSEnvironment dict", plistPath],
|
||||
],
|
||||
["/usr/libexec/PlistBuddy", ["-c", "Add :LSEnvironment dict", plistPath]],
|
||||
[
|
||||
"/usr/libexec/PlistBuddy",
|
||||
["-c", "Delete :LSEnvironment:ONE_MEMORY_VERSION", plistPath],
|
||||
|
||||
@@ -229,6 +229,5 @@ contextBridge.exposeInMainWorld("oneMemory", {
|
||||
hostId: string;
|
||||
installed: boolean;
|
||||
permission: RecallPermissionStatus;
|
||||
}> =>
|
||||
ipcRenderer.invoke("one-memory:uninstall-recall", hostId, approved),
|
||||
}> => ipcRenderer.invoke("one-memory:uninstall-recall", hostId, approved),
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { mkdir, mkdtemp, readFile, readlink, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -7,8 +13,8 @@ import {
|
||||
ensureStableRuntimeThenRepair,
|
||||
installBundledCliIfNeeded,
|
||||
isInstalledApplicationsApp,
|
||||
startStableRuntimeRepair,
|
||||
stableRuntimeErrorCode,
|
||||
startStableRuntimeRepair,
|
||||
} from "./stable-runtime";
|
||||
|
||||
it("only treats a packaged macOS executable under Applications as installed", () => {
|
||||
@@ -35,7 +41,12 @@ it("only treats a packaged macOS executable under Applications as installed", ()
|
||||
it("installs only memhub and adds its directory to a new shell", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const home = await mkdtemp(path.join(os.tmpdir(), "memhub-auto-cli-"));
|
||||
const installRoot = path.join(home, "Library", "Application Support", "One Memory");
|
||||
const installRoot = path.join(
|
||||
home,
|
||||
"Library",
|
||||
"Application Support",
|
||||
"One Memory",
|
||||
);
|
||||
const target = path.join(installRoot, "current", "bin", "one-memory");
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, "#!/bin/sh\n", { mode: 0o700 });
|
||||
@@ -43,10 +54,14 @@ it("installs only memhub and adds its directory to a new shell", async () => {
|
||||
await installBundledCliIfNeeded({ home, installRoot, platform: "darwin" });
|
||||
await installBundledCliIfNeeded({ home, installRoot, platform: "darwin" });
|
||||
|
||||
expect(await readlink(path.join(home, ".local", "bin", "memhub"))).toBe(target);
|
||||
expect(await readlink(path.join(home, ".local", "bin", "memhub"))).toBe(
|
||||
target,
|
||||
);
|
||||
const profile = await readFile(path.join(home, ".zprofile"), "utf8");
|
||||
expect(profile.match(/# >>> memhub PATH >>>/g)).toHaveLength(1);
|
||||
await expect(readFile(path.join(home, ".local", "bin", "one-memory"))).rejects.toThrow();
|
||||
await expect(
|
||||
readFile(path.join(home, ".local", "bin", "one-memory")),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
describe("ensureStableRuntimeThenRepair", () => {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { installCliLink, planCliLink } from "@one-memory/runtime-control";
|
||||
|
||||
@@ -36,13 +44,22 @@ async function ensureCliPath(
|
||||
binDirectory: string,
|
||||
platform: NodeJS.Platform,
|
||||
): Promise<void> {
|
||||
const profile = path.join(home, platform === "darwin" ? ".zprofile" : ".profile");
|
||||
const profile = path.join(
|
||||
home,
|
||||
platform === "darwin" ? ".zprofile" : ".profile",
|
||||
);
|
||||
const existingInfo = await lstat(profile).catch(() => null);
|
||||
if (existingInfo?.isSymbolicLink() || (existingInfo && !existingInfo.isFile())) {
|
||||
if (
|
||||
existingInfo?.isSymbolicLink() ||
|
||||
(existingInfo && !existingInfo.isFile())
|
||||
) {
|
||||
throw new Error("shell_profile_unmanaged");
|
||||
}
|
||||
const existing = existingInfo ? await readFile(profile, "utf8") : "";
|
||||
if (existing.includes(CLI_PATH_BEGIN) || existing.split("\n").some((line) => line.includes(binDirectory))) {
|
||||
if (
|
||||
existing.includes(CLI_PATH_BEGIN) ||
|
||||
existing.split("\n").some((line) => line.includes(binDirectory))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await mkdir(home, { recursive: true, mode: 0o700 });
|
||||
@@ -50,7 +67,10 @@ async function ensureCliPath(
|
||||
const block = `${CLI_PATH_BEGIN}\nexport PATH="$HOME/.local/bin:$PATH"\n${CLI_PATH_END}\n`;
|
||||
const temporary = `${profile}.memhub-${process.pid}`;
|
||||
try {
|
||||
await writeFile(temporary, `${existing}${separator}${block}`, { encoding: "utf8", mode: 0o600 });
|
||||
await writeFile(temporary, `${existing}${separator}${block}`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await chmod(temporary, existingInfo ? existingInfo.mode & 0o777 : 0o600);
|
||||
await rename(temporary, profile);
|
||||
} finally {
|
||||
|
||||
@@ -3,8 +3,8 @@ export {
|
||||
hasSyncHookArtifacts,
|
||||
loadSyncHookAssets,
|
||||
orphanSyncHookIdentity,
|
||||
syncHookInput,
|
||||
syncHookPreview,
|
||||
type SyncHookPreview,
|
||||
type SyncHookStatus,
|
||||
syncHookInput,
|
||||
syncHookPreview,
|
||||
} from "@one-memory/runtime-control";
|
||||
@@ -1,10 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
import { createRequire } from "node:module";
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { resolveBuildVersion } = require("../../scripts/release-version.cjs") as {
|
||||
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
|
||||
};
|
||||
const { resolveBuildVersion } =
|
||||
require("../../scripts/release-version.cjs") as {
|
||||
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
|
||||
};
|
||||
const releaseVersion = resolveBuildVersion(process.env);
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#4d6bfe" d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.5 5.5 0 0 1-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11 11 0 0 0-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428s-1.67.295-2.687.684a3 3 0 0 1-.465.137 9.6 9.6 0 0 0-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.2 4.2 0 0 0 1.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.7 4.7 0 0 1 1.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614m1-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .2.288.306.306 0 0 1-.31.307.303.303 0 0 1-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.552-.758a1.7 1.7 0 0 1 .016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.56.56 0 0 1-.254-.078.253.253 0 0 1-.114-.358c.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>DeepSeek</title><path fill="#4d6bfe" d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.5 5.5 0 0 1-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11 11 0 0 0-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428s-1.67.295-2.687.684a3 3 0 0 1-.465.137 9.6 9.6 0 0 0-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.2 4.2 0 0 0 1.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.7 4.7 0 0 1 1.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614m1-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .2.288.306.306 0 0 1-.31.307.303.303 0 0 1-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.552-.758a1.7 1.7 0 0 1 .016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.56.56 0 0 1-.254-.078.253.253 0 0 1-.114-.358c.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#1c1b1a" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M5.938 12.835c.127-.039.285.02.373.143.028.038.036.092.046.14.003.014-.02.033-.04.05q-.184-.146-.354-.291c-.011-.01-.016-.027-.025-.042m2.458-3.423c.195-.032.39-.06.588-.05a.5.5 0 0 1 .148.026q.303.107.601.224c.028.01.05.036.075.055l-.013.027a9 9 0 0 1-.26-.089c-.115-.038-.213-.077-.315-.098-.25-.05-.25-.046-.292-.014l.574.144q.413.207.823.417c.042.022.09.057.107.098.026.06.063.076.117.072.066-.006.132-.017.213-.027l-.04.086c.051.08.142.02.216.064-.074.13-.247.09-.334.199l.061.074-.12.087c0 .106-.038.168-.306.243l.026.085-.196.042.07.124h-.25l-.007.137-.244-.027-.053.123c-.027-.008-.052-.011-.073-.023-.067-.038-.128-.056-.195.006-.019.017-.063.014-.093.008-.026-.006-.05-.029-.07-.042-.11.095-.11.095-.208.003-.057.046-.12.074-.186.011-.063.027-.123-.02-.178-.014-.07.007-.097-.035-.133-.07l-.13.033c-.013-.236-.194-.19-.34-.203.005-.072.05-.092.095-.094a.5.5 0 0 1 .159.022c.164.05.32.12.496.138.203.021.405.029.601-.015.265-.059.52-.149.707-.365.049-.056.083-.127.117-.195.019-.038.02-.084-.02-.116a1.4 1.4 0 0 0-.382-.217c.024.12-.031.182-.115.221 0 .014-.004.025 0 .03.08.115.084.16-.007.267a1.4 1.4 0 0 1-.218.211.477.477 0 0 1-.641-.05 1.4 1.4 0 0 1-.133-.152c-.078-.107-.076-.108-.033-.236-.165-.08-.128-.226-.104-.364.008-.05.028-.096.049-.163-.04.014-.067.017-.087.032a.9.9 0 0 0-.316.357c-.007.016-.01.034-.02.047-.012.015-.034.038-.045.035-.02-.006-.037-.027-.05-.045-.008-.012-.007-.032-.012-.057h-.126l.053-.172-.039-.049.11-.284c-.06.026-.091.044-.124.051-.03.007-.064 0-.095 0 0-.031-.01-.07.004-.092.149-.22.305-.428.593-.476"/><path d="M8.06 10.788c-.003-.038-.004-.075.037-.062.016.006.034.048.028.067-.01.04-.038.032-.064-.005z"/><path d="M11.981.009q.34-.017.679 0c.247.01.495.024.74.062q.602.097 1.19.273c.463.138.92.299 1.356.511a7.3 7.3 0 0 1 2.948 2.642q.436.704.739 1.479c.219.556.446 1.11.623 1.683.204.654.329 1.326.458 1.997.097.504.182 1.01.29 1.511.156.722.329 1.44.494 2.16.186.812.4 1.615.63 2.415.102.355.193.713.282 1.072.11.436.202.876.254 1.323.031.278.066.557.073.837a8 8 0 0 1-.017.88c-.037.413-.1.818-.226 1.212a5 5 0 0 1-.915 1.649l-.13.156.018.023c.043-.023.088-.041.127-.068.2-.138.373-.307.531-.49.4-.46.721-.973.975-1.529a3.6 3.6 0 0 0 .325-1.72c-.024-.424-.097-.834-.3-1.213-.013-.027-.015-.06-.03-.121.05.035.082.048.101.072.107.13.22.258.315.398.33.494.46 1.052.486 1.64a3.75 3.75 0 0 1-.47 1.97c-.36.655-.887 1.14-1.526 1.506-.193.111-.394.21-.595.308-.157.078-.248.211-.318.365a.52.52 0 0 0-.033.406.4.4 0 0 1 .013.139c-.005.077-.077.155-.14.162-.054.006-.125-.043-.15-.116a1.2 1.2 0 0 1-.06-.233c-.04-.314-.155-.6-.308-.87a3.9 3.9 0 0 0-.73-.91 2.1 2.1 0 0 0-.897-.524 4 4 0 0 0-.692-.131c-.075-.008-.15-.04-.22.01.18.06.363.11.538.18.434.173.82.43 1.18.728.308.255.58.543.794.884.098.155.186.315.227.496.027.123.042.25.067.375q.021.092-.053.144c-.047.033-.122.034-.163-.01a.5.5 0 0 1-.08-.14c-.03-.073-.038-.159-.078-.225a7.3 7.3 0 0 0-1.423-1.664c-.16-.137-.329-.26-.537-.323-.376-.114-.753-.203-1.15-.154-.213.025-.427.032-.64.053a1.6 1.6 0 0 0-.736.278 5 5 0 0 0-.834.72c-.329.342-.642.699-.955 1.055-.136.155-.264.319-.314.531l-.012.051a.096.096 0 0 1-.09.076h-.31c-.046 0-.082-.048-.072-.094q.033-.162.07-.324c.075-.325.19-.635.368-.917.024-.039.04-.088.104-.08l.01.049.027.077c.28-.435.571-.834.996-1.135.283-.204.584-.378.89-.55a.2.2 0 0 0-.098-.002c-.162.043-.325.084-.485.134-.402.124-.764.33-1.11.566-.147.1-.298.193-.414.333a7.3 7.3 0 0 0-1.07 1.767 1 1 0 0 0-.04.12.075.075 0 0 1-.072.056h-.494c-.04 0-.062-.051-.036-.082.123-.14.246-.282.377-.415.275-.281.58-.532.777-.884.027-.048.063-.09.095-.135.238-.333.54-.607.818-.902.082-.086.175-.16.26-.24q.042-.042.079-.085l-.018-.025-.135.041q-.053.023-.102.05c-.248.144-.494.292-.743.433-.408.23-.825.439-1.209.711-.281.2-.591.358-.889.533-.02.012-.044.015-.08.028-.015-.135.143-.201.108-.336-.033.014-.064.02-.085.038-.111.096-.227.19-.328.296-.148.157-.284.325-.425.488q-.188.214-.373.431A.15.15 0 0 1 9.89 24H8.762l.016-.042c.028-.09.085-.172.083-.28-.091-.018-.162.001-.212.077a5 5 0 0 0-.136.215c-.01.016-.024.03-.042.03h-.093c-.019 0-.029-.022-.017-.037q.106-.133.209-.268c.001-.002-.006-.012-.012-.024q-.022.004-.045.013-.264.135-.527.274a.4.4 0 0 1-.168.042H5.202c-.026 0-.039-.036-.019-.053a3 3 0 0 0 .558-.605c.335-.496.538-1.047.667-1.629.004-.02-.003-.043-.006-.091-.037.048-.059.072-.076.1a2 2 0 0 1-.334.415c-.28.258-.59.448-.983.464-.297.012-.588 0-.865-.127-.46-.21-.722-.57-.794-1.072-.025-.17-.017-.171-.182-.219A3.5 3.5 0 0 1 1.97 20.6a2.3 2.3 0 0 1-.808-1.13 3.6 3.6 0 0 1-.16-1.245c.002-.034.016-.067.024-.1.032.023.046.043.05.066.033.153.059.308.096.46q.128.534.516.92c.258.256.571.419.91.532.358.118.717.138 1.07-.016a1.9 1.9 0 0 0 .621-.452c.328-.348.533-.76.648-1.223.009-.034.005-.071.007-.11-.015.006-.026.006-.03.011q-.048.075-.093.152A2.7 2.7 0 0 1 3.625 19.6c-.351.17-.718.255-1.11.159a1.6 1.6 0 0 1-.971-.64 2 2 0 0 1-.368-.924 2.9 2.9 0 0 1 .02-Line truncated
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#1c1b1a" fill-rule="evenodd" viewBox="0 0 24 24"><title>Hermes</title><path d="M5.938 12.835c.127-.039.285.02.373.143.028.038.036.092.046.14.003.014-.02.033-.04.05q-.184-.146-.354-.291c-.011-.01-.016-.027-.025-.042m2.458-3.423c.195-.032.39-.06.588-.05a.5.5 0 0 1 .148.026q.303.107.601.224c.028.01.05.036.075.055l-.013.027a9 9 0 0 1-.26-.089c-.115-.038-.213-.077-.315-.098-.25-.05-.25-.046-.292-.014l.574.144q.413.207.823.417c.042.022.09.057.107.098.026.06.063.076.117.072.066-.006.132-.017.213-.027l-.04.086c.051.08.142.02.216.064-.074.13-.247.09-.334.199l.061.074-.12.087c0 .106-.038.168-.306.243l.026.085-.196.042.07.124h-.25l-.007.137-.244-.027-.053.123c-.027-.008-.052-.011-.073-.023-.067-.038-.128-.056-.195.006-.019.017-.063.014-.093.008-.026-.006-.05-.029-.07-.042-.11.095-.11.095-.208.003-.057.046-.12.074-.186.011-.063.027-.123-.02-.178-.014-.07.007-.097-.035-.133-.07l-.13.033c-.013-.236-.194-.19-.34-.203.005-.072.05-.092.095-.094a.5.5 0 0 1 .159.022c.164.05.32.12.496.138.203.021.405.029.601-.015.265-.059.52-.149.707-.365.049-.056.083-.127.117-.195.019-.038.02-.084-.02-.116a1.4 1.4 0 0 0-.382-.217c.024.12-.031.182-.115.221 0 .014-.004.025 0 .03.08.115.084.16-.007.267a1.4 1.4 0 0 1-.218.211.477.477 0 0 1-.641-.05 1.4 1.4 0 0 1-.133-.152c-.078-.107-.076-.108-.033-.236-.165-.08-.128-.226-.104-.364.008-.05.028-.096.049-.163-.04.014-.067.017-.087.032a.9.9 0 0 0-.316.357c-.007.016-.01.034-.02.047-.012.015-.034.038-.045.035-.02-.006-.037-.027-.05-.045-.008-.012-.007-.032-.012-.057h-.126l.053-.172-.039-.049.11-.284c-.06.026-.091.044-.124.051-.03.007-.064 0-.095 0 0-.031-.01-.07.004-.092.149-.22.305-.428.593-.476"/><path d="M8.06 10.788c-.003-.038-.004-.075.037-.062.016.006.034.048.028.067-.01.04-.038.032-.064-.005z"/><path d="M11.981.009q.34-.017.679 0c.247.01.495.024.74.062q.602.097 1.19.273c.463.138.92.299 1.356.511a7.3 7.3 0 0 1 2.948 2.642q.436.704.739 1.479c.219.556.446 1.11.623 1.683.204.654.329 1.326.458 1.997.097.504.182 1.01.29 1.511.156.722.329 1.44.494 2.16.186.812.4 1.615.63 2.415.102.355.193.713.282 1.072.11.436.202.876.254 1.323.031.278.066.557.073.837a8 8 0 0 1-.017.88c-.037.413-.1.818-.226 1.212a5 5 0 0 1-.915 1.649l-.13.156.018.023c.043-.023.088-.041.127-.068.2-.138.373-.307.531-.49.4-.46.721-.973.975-1.529a3.6 3.6 0 0 0 .325-1.72c-.024-.424-.097-.834-.3-1.213-.013-.027-.015-.06-.03-.121.05.035.082.048.101.072.107.13.22.258.315.398.33.494.46 1.052.486 1.64a3.75 3.75 0 0 1-.47 1.97c-.36.655-.887 1.14-1.526 1.506-.193.111-.394.21-.595.308-.157.078-.248.211-.318.365a.52.52 0 0 0-.033.406.4.4 0 0 1 .013.139c-.005.077-.077.155-.14.162-.054.006-.125-.043-.15-.116a1.2 1.2 0 0 1-.06-.233c-.04-.314-.155-.6-.308-.87a3.9 3.9 0 0 0-.73-.91 2.1 2.1 0 0 0-.897-.524 4 4 0 0 0-.692-.131c-.075-.008-.15-.04-.22.01.18.06.363.11.538.18.434.173.82.43 1.18.728.308.255.58.543.794.884.098.155.186.315.227.496.027.123.042.25.067.375q.021.092-.053.144c-.047.033-.122.034-.163-.01a.5.5 0 0 1-.08-.14c-.03-.073-.038-.159-.078-.225a7.3 7.3 0 0 0-1.423-1.664c-.16-.137-.329-.26-.537-.323-.376-.114-.753-.203-1.15-.154-.213.025-.427.032-.64.053a1.6 1.6 0 0 0-.736.278 5 5 0 0 0-.834.72c-.329.342-.642.699-.955 1.055-.136.155-.264.319-.314.531l-.012.051a.096.096 0 0 1-.09.076h-.31c-.046 0-.082-.048-.072-.094q.033-.162.07-.324c.075-.325.19-.635.368-.917.024-.039.04-.088.104-.08l.01.049.027.077c.28-.435.571-.834.996-1.135.283-.204.584-.378.89-.55a.2.2 0 0 0-.098-.002c-.162.043-.325.084-.485.134-.402.124-.764.33-1.11.566-.147.1-.298.193-.414.333a7.3 7.3 0 0 0-1.07 1.767 1 1 0 0 0-.04.12.075.075 0 0 1-.072.056h-.494c-.04 0-.062-.051-.036-.082.123-.14.246-.282.377-.415.275-.281.58-.532.777-.884.027-.048.063-.09.095-.135.238-.333.54-.607.818-.902.082-.086.175-.16.26-.24q.042-.042.079-.085l-.018-.025-.135.041q-.053.023-.102.05c-.248.144-.494.292-.743.433-.408.23-.825.439-1.209.711-.281.2-.591.358-.889.533-.02.012-.044.015-.08.028-.015-.135.143-.201.108-.336-.033.014-.064.02-.085.038-.111.096-.227.19-.328.296-.148.157-.284.325-.425.488q-.188.214-.373.431A.15.15 0 0 1 9.89 24H8.762l.016-.042c.028-.09.085-.172.083-.28-.091-.018-.162.001-.212.077a5 5 0 0 0-.136.215c-.01.016-.024.03-.042.03h-.093c-.019 0-.029-.022-.017-.037q.106-.133.209-.268c.001-.002-.006-.012-.012-.024q-.022.004-.045.013-.264.135-.527.274a.4.4 0 0 1-.168.042H5.202c-.026 0-.039-.036-.019-.053a3 3 0 0 0 .558-.605c.335-.496.538-1.047.667-1.629.004-.02-.003-.043-.006-.091-.037.048-.059.072-.076.1a2 2 0 0 1-.334.415c-.28.258-.59.448-.983.464-.297.012-.588 0-.865-.127-.46-.21-.722-.57-.794-1.072-.025-.17-.017-.171-.182-.219A3.5 3.5 0 0 1 1.97 20.6a2.3 2.3 0 0 1-.808-1.13 3.6 3.6 0 0 1-.16-1.245c.002-.034.016-.067.024-.1.032.023.046.043.05.066.033.153.059.308.096.46q.128.534.516.92c.258.256.571.419.91.532.358.118.717.138 1.07-.016a1.9 1.9 0 0 0 .621-.452c.328-.348.533-.76.648-1.223.009-.034.005-.071.007-.11-.015.006-.026.006-.03.011q-.048.075-.093.152A2.7 2.7 0 0 1 3.625 19.6c-.351.17-.718.255-1.11.159a1.6 1.6 0 0 1-.971-.64 2 2 0 0 1-.368-.9Line truncated
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg"><title>OpenClaw</title>
|
||||
<defs>
|
||||
<linearGradient id="ocLG" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff4d4d"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#1c1b1a" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M16 6H8v12h8zm4 16H4V2h16z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#1c1b1a" fill-rule="evenodd" viewBox="0 0 24 24"><title>OpenCode</title><path d="M16 6H8v12h8zm4 16H4V2h16z"/></svg>
|
||||
|
Before Width: | Height: | Size: 139 B After Width: | Height: | Size: 162 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800"><rect width="800" height="800" rx="120" fill="#09090b"/><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/><path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800"><title>Pi</title><rect width="800" height="800" rx="120" fill="#09090b"/><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/><path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 314 B After Width: | Height: | Size: 331 B |
@@ -1028,20 +1028,20 @@ it("does not keep WorkBuddy looking synced when the server write fails", async (
|
||||
screen.getByRole("switch", { name: "同步开关" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("alert").compareDocumentPosition(
|
||||
screen.getByRole("switch", { name: "同步开关" }),
|
||||
) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
screen
|
||||
.getByRole("alert")
|
||||
.compareDocumentPosition(
|
||||
screen.getByRole("switch", { name: "同步开关" }),
|
||||
) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables connection and explains why outside Applications, and stays enabled when in Applications", async () => {
|
||||
window.oneMemory = {
|
||||
getConnectionSetupCapability: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
available: false,
|
||||
reason: "app_not_in_applications",
|
||||
}),
|
||||
getConnectionSetupCapability: vi.fn().mockResolvedValue({
|
||||
available: false,
|
||||
reason: "app_not_in_applications",
|
||||
}),
|
||||
} as unknown as NonNullable<typeof window.oneMemory>;
|
||||
render(<ApplicationDetail {...props} />);
|
||||
await waitFor(() =>
|
||||
@@ -1106,13 +1106,9 @@ it("promises a backfill when the switch is turned back on (D97)", async () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/将补充冻结期间的新对话/)).toBeNull();
|
||||
fireEvent.click(
|
||||
await screen.findByRole("switch", { name: "同步开关" }),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("switch", { name: "同步开关" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/将补充冻结期间的新对话/),
|
||||
).toBeTruthy(),
|
||||
expect(screen.getByText(/将补充冻结期间的新对话/)).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1135,15 +1131,11 @@ it("says sync is being turned on while the write is in flight (D77)", async () =
|
||||
);
|
||||
const sync = await screen.findByRole("switch", { name: "同步开关" });
|
||||
fireEvent.click(sync);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("正在开启处理…")).toBeTruthy(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("正在开启处理…")).toBeTruthy());
|
||||
expect(screen.getByRole("status").textContent).toContain("一两分钟");
|
||||
expect(screen.queryByText("处理中")).toBeNull();
|
||||
finish();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("正在开启处理…")).toBeNull(),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText("正在开启处理…")).toBeNull());
|
||||
});
|
||||
|
||||
it("sends a click made while the remembered sync is still writing (D77)", async () => {
|
||||
|
||||
@@ -517,7 +517,9 @@ export function ApplicationDetail({
|
||||
)}
|
||||
{hostError && <p role="alert">{hostError}</p>}
|
||||
{appLocationNote && <p role="status">{appLocationNote}</p>}
|
||||
{hostRestartNotice ? <p role="status">{hostRestartNotice}</p> : null}
|
||||
{hostRestartNotice ? (
|
||||
<p role="status">{hostRestartNotice}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<R6Switch
|
||||
label="一键插入应用"
|
||||
|
||||
@@ -19,15 +19,21 @@ import { saveConnectionPreferences } from "./connection-preferences";
|
||||
import { resetConnectionReconcile } from "./connection-reconcile";
|
||||
import { queryClient } from "./query-client";
|
||||
|
||||
const { summary, processing, permission, history, liveSync, recentConversations } =
|
||||
vi.hoisted(() => ({
|
||||
summary: vi.fn(),
|
||||
processing: vi.fn(),
|
||||
permission: vi.fn(),
|
||||
history: vi.fn(),
|
||||
liveSync: vi.fn(),
|
||||
recentConversations: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
summary,
|
||||
processing,
|
||||
permission,
|
||||
history,
|
||||
liveSync,
|
||||
recentConversations,
|
||||
} = vi.hoisted(() => ({
|
||||
summary: vi.fn(),
|
||||
processing: vi.fn(),
|
||||
permission: vi.fn(),
|
||||
history: vi.fn(),
|
||||
liveSync: vi.fn(),
|
||||
recentConversations: vi.fn(),
|
||||
}));
|
||||
vi.mock("./call-history-client", async (original) => ({
|
||||
...(await original<typeof import("./call-history-client")>()),
|
||||
getCallHistory: history,
|
||||
@@ -116,7 +122,7 @@ it("hides a debug-frame dialogue excerpt from 最近更新 and falls back to mem
|
||||
created_at: "2026-10-12T06:06:00Z",
|
||||
imported_at: "2026-10-12T06:06:00Z",
|
||||
time_basis: "conversation",
|
||||
excerpt: '[write_stdout] {"chars":"{\\\"session_id\\":93099}',
|
||||
excerpt: '[write_stdout] {"chars":"{\\"session_id\\":93099}',
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
@@ -556,9 +562,7 @@ it("shows disabled model-content permission and opens settings", async () => {
|
||||
0,
|
||||
);
|
||||
expect(screen.getByText("新的对话不会再整理成记忆")).toBeTruthy();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "打开模型内容权限" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "打开模型内容权限" }));
|
||||
expect(onSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
@@ -31,13 +31,13 @@ import {
|
||||
retryAutoReconcile,
|
||||
useAutoConnect,
|
||||
} from "./connection-reconcile";
|
||||
import { HomeCardBoundary } from "./HomeCardBoundary";
|
||||
import {
|
||||
hostBridgeAvailable,
|
||||
hostConnectionApproved,
|
||||
hostConnectionUnknown,
|
||||
useRecallHosts,
|
||||
} from "./host-state";
|
||||
import { HomeCardBoundary } from "./HomeCardBoundary";
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { prefetchDataRuntime } from "./runtime-state";
|
||||
@@ -804,214 +804,214 @@ export function ApplicationsHome({
|
||||
inert={expanded || phase !== "idle"}
|
||||
>
|
||||
<HomeCardBoundary label="记忆概览">
|
||||
<section className="r6-home-overview" aria-label="记忆概览">
|
||||
<header>
|
||||
<h2>记忆概览</h2>
|
||||
</header>
|
||||
<div className="r6-home-metrics">
|
||||
<div>
|
||||
<p>已沉淀记忆</p>
|
||||
{/* The figure slot used to hold the word 读取中 while the first
|
||||
<section className="r6-home-overview" aria-label="记忆概览">
|
||||
<header>
|
||||
<h2>记忆概览</h2>
|
||||
</header>
|
||||
<div className="r6-home-metrics">
|
||||
<div>
|
||||
<p>已沉淀记忆</p>
|
||||
{/* The figure slot used to hold the word 读取中 while the first
|
||||
read was in flight, which is how "大数字不可见" (D51) shows up
|
||||
on a slow link: the biggest type on the page spends the first
|
||||
frame saying something that is not a number. A bar of the same
|
||||
height reserves the digits instead, and the wording moves to a
|
||||
live region so screen readers still hear it. 暂无法读取 stays a
|
||||
word, because that is a settled state rather than progress. */}
|
||||
{!visible && !summaryUnavailable ? (
|
||||
<>
|
||||
<p className="r6-home-figure">
|
||||
<span
|
||||
className="r6-home-figure-pending"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</p>
|
||||
<small role="status">正在读取已沉淀记忆数量…</small>
|
||||
</>
|
||||
{!visible && !summaryUnavailable ? (
|
||||
<>
|
||||
<p className="r6-home-figure">
|
||||
<span
|
||||
className="r6-home-figure-pending"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</p>
|
||||
<small role="status">正在读取已沉淀记忆数量…</small>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="r6-home-figure">
|
||||
<strong className={visible ? "" : "r6-home-word"}>
|
||||
{visible
|
||||
? new Intl.NumberFormat("zh-CN").format(
|
||||
visible.memory_count,
|
||||
)
|
||||
: "暂无法读取"}
|
||||
</strong>
|
||||
{visible ? <i>条</i> : null}
|
||||
</p>
|
||||
<small>按应用授权范围使用</small>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p>沉淀状态</p>
|
||||
<strong className="r6-home-word">{stateLabel}</strong>
|
||||
<small>
|
||||
{modelContentAllowed === false
|
||||
? "新的对话不会再整理成记忆"
|
||||
: processing?.running
|
||||
? "新的对话正在整理"
|
||||
: processing?.pending
|
||||
? "等待队列开始处理"
|
||||
: "以当前处理状态为准"}
|
||||
</small>
|
||||
{modelContentAllowed === false ? (
|
||||
<R6Button variant="quiet" onClick={() => onSettings()}>
|
||||
打开模型内容权限
|
||||
</R6Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<p>最近更新</p>
|
||||
<strong className={time ? "r6-home-time" : "r6-home-word"}>
|
||||
{time ?? (visible ? "尚未生成" : "待核对")}
|
||||
</strong>
|
||||
<small>
|
||||
{time
|
||||
? `${latestClock?.today ? "今天 · " : ""}记忆已保存`
|
||||
: "历史与记忆分别处理"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="r6-home-activity">
|
||||
{availableActivities.length > 1 &&
|
||||
(activity === "call" ||
|
||||
activity === "memory" ||
|
||||
activity === "dialogue") ? (
|
||||
<div className="r6-home-activity-tabs" role="tablist">
|
||||
{(["update", "call"] as const).map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
key={item}
|
||||
aria-selected={
|
||||
item === "call"
|
||||
? activity === "call"
|
||||
: activity === "memory" || activity === "dialogue"
|
||||
}
|
||||
onClick={() => setActivityView(item)}
|
||||
>
|
||||
{item === "call" ? "最近调用" : "最近更新"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="r6-home-figure">
|
||||
<strong className={visible ? "" : "r6-home-word"}>
|
||||
{visible
|
||||
? new Intl.NumberFormat("zh-CN").format(
|
||||
visible.memory_count,
|
||||
)
|
||||
: "暂无法读取"}
|
||||
</strong>
|
||||
{visible ? <i>条</i> : null}
|
||||
</p>
|
||||
<small>按应用授权范围使用</small>
|
||||
</>
|
||||
<p>
|
||||
{activity === "unavailable"
|
||||
? "动态暂不可用"
|
||||
: activity === "call"
|
||||
? "最近调用"
|
||||
: activity === "dialogue" || activity === "memory"
|
||||
? "最近更新"
|
||||
: "首批沉淀"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p>沉淀状态</p>
|
||||
<strong className="r6-home-word">{stateLabel}</strong>
|
||||
<small>
|
||||
{modelContentAllowed === false
|
||||
? "新的对话不会再整理成记忆"
|
||||
: processing?.running
|
||||
? "新的对话正在整理"
|
||||
: processing?.pending
|
||||
? "等待队列开始处理"
|
||||
: "以当前处理状态为准"}
|
||||
</small>
|
||||
{modelContentAllowed === false ? (
|
||||
<R6Button variant="quiet" onClick={() => onSettings()}>
|
||||
打开模型内容权限
|
||||
</R6Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<p>最近更新</p>
|
||||
<strong className={time ? "r6-home-time" : "r6-home-word"}>
|
||||
{time ?? (visible ? "尚未生成" : "待核对")}
|
||||
</strong>
|
||||
<small>
|
||||
{time
|
||||
? `${latestClock?.today ? "今天 · " : ""}记忆已保存`
|
||||
: "历史与记忆分别处理"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="r6-home-activity">
|
||||
{availableActivities.length > 1 &&
|
||||
(activity === "call" ||
|
||||
activity === "memory" ||
|
||||
activity === "dialogue") ? (
|
||||
<div className="r6-home-activity-tabs" role="tablist">
|
||||
{(["update", "call"] as const).map((item) => (
|
||||
<div>
|
||||
{activity === "call" && recentCall ? (
|
||||
<div className="r6-home-call-direction" aria-hidden="true">
|
||||
{recentCallSource ? (
|
||||
<>
|
||||
<AppMark id={recentCallSource.source_id} size={26} />
|
||||
<ArrowRight size={22} />
|
||||
</>
|
||||
) : null}
|
||||
<AppMark
|
||||
id={
|
||||
recentCallHost?.source_id ??
|
||||
recentCall.host_id ??
|
||||
"unknown"
|
||||
}
|
||||
size={26}
|
||||
/>
|
||||
</div>
|
||||
) : activity === "memory" && latest?.source_ids[0] ? (
|
||||
(() => {
|
||||
const source = imports.sources.find(
|
||||
(item) => item.source_id === latest.source_ids[0],
|
||||
);
|
||||
return source ? <SourceMark source={source} /> : null;
|
||||
})()
|
||||
) : activity === "dialogue" && latestDialogue ? (
|
||||
(() => {
|
||||
const source = imports.sources.find(
|
||||
(item) => item.source_id === latestDialogue.source_id,
|
||||
);
|
||||
return source ? <SourceMark source={source} /> : null;
|
||||
})()
|
||||
) : null}
|
||||
<section>
|
||||
<h3>
|
||||
{activity === "unavailable"
|
||||
? callState === "unavailable"
|
||||
? "暂时无法读取调用记录"
|
||||
: "暂时无法读取记忆状态"
|
||||
: activity === "call" && recentCall
|
||||
? callDetailHeadline(
|
||||
recentCall,
|
||||
recentCallHost?.display_name ??
|
||||
callHostName(recentCall.host_id),
|
||||
)
|
||||
: activity === "memory" && latest
|
||||
? `${latest.content}${latest.truncated ? "…" : ""}`
|
||||
: activity === "dialogue" && latestDialogue
|
||||
? `${latestDialogue.excerpt}${latestDialogue.truncated ? "…" : ""}`
|
||||
: activity === "importing"
|
||||
? "对话持续导入中,即将沉淀你的记忆"
|
||||
: activity === "first-running"
|
||||
? "正在沉淀第一批记忆"
|
||||
: activity === "first-queued"
|
||||
? "准备沉淀第一批记忆"
|
||||
: activity === "loading"
|
||||
? "正在读取动态"
|
||||
: "还没有已生成的记忆"}
|
||||
</h3>
|
||||
<small>
|
||||
{activity === "unavailable"
|
||||
? callState === "unavailable"
|
||||
? "已有记忆仍可查看;重试不会发起新的调用。"
|
||||
: "读取失败不会当作没有记忆。"
|
||||
: activity === "call" && recentCall
|
||||
? `${stamped(recentCall.occurred_at)} · ${callRowDetail(recentCall)}${callState === "incomplete" ? " · 有几次调用没被记下来,列表可能不全" : ""}`
|
||||
: activity === "memory" && latest
|
||||
? `${time} · ${memoryAction(latest.action)} · ${latest.source_ids.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join("、")} · 版本 ${latest.revision}`
|
||||
: activity === "dialogue" && latestDialogue
|
||||
? `${clockParts(latestDialogue.created_at ?? latestDialogue.imported_at)?.clock ?? "时间未知"} ${latestDialogue.time_basis === "conversation" ? "对话时间" : "导入时间"} · ${imports.sources.find((source) => source.source_id === latestDialogue.source_id)?.display_name ?? latestDialogue.source_id}`
|
||||
: activity === "importing"
|
||||
? "对话正在后台持续保存。"
|
||||
: "历史已保存,记忆生成后会显示在这里。"}
|
||||
</small>
|
||||
</section>
|
||||
{(activity === "unavailable" ||
|
||||
(activity === "call" && CALL_HISTORY_ENTRY_VISIBLE) ||
|
||||
activity === "memory") && (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
key={item}
|
||||
aria-selected={
|
||||
item === "call"
|
||||
? activity === "call"
|
||||
: activity === "memory" || activity === "dialogue"
|
||||
className="r6-home-activity-link"
|
||||
onClick={
|
||||
activity === "unavailable"
|
||||
? () => {
|
||||
setRevision((n) => n + 1);
|
||||
setCallRevision((n) => n + 1);
|
||||
}
|
||||
: activity === "call"
|
||||
? onCalls
|
||||
: () => onMemories(latest?.memory_id)
|
||||
}
|
||||
onClick={() => setActivityView(item)}
|
||||
>
|
||||
{item === "call" ? "最近调用" : "最近更新"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
{activity === "unavailable"
|
||||
? "动态暂不可用"
|
||||
: activity === "call"
|
||||
? "最近调用"
|
||||
: activity === "dialogue" || activity === "memory"
|
||||
? "最近更新"
|
||||
: "首批沉淀"}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
{activity === "call" && recentCall ? (
|
||||
<div className="r6-home-call-direction" aria-hidden="true">
|
||||
{recentCallSource ? (
|
||||
<>
|
||||
<AppMark id={recentCallSource.source_id} size={26} />
|
||||
<ArrowRight size={22} />
|
||||
</>
|
||||
) : null}
|
||||
<AppMark
|
||||
id={
|
||||
recentCallHost?.source_id ??
|
||||
recentCall.host_id ??
|
||||
"unknown"
|
||||
}
|
||||
size={26}
|
||||
/>
|
||||
</div>
|
||||
) : activity === "memory" && latest?.source_ids[0] ? (
|
||||
(() => {
|
||||
const source = imports.sources.find(
|
||||
(item) => item.source_id === latest.source_ids[0],
|
||||
);
|
||||
return source ? <SourceMark source={source} /> : null;
|
||||
})()
|
||||
) : activity === "dialogue" && latestDialogue ? (
|
||||
(() => {
|
||||
const source = imports.sources.find(
|
||||
(item) => item.source_id === latestDialogue.source_id,
|
||||
);
|
||||
return source ? <SourceMark source={source} /> : null;
|
||||
})()
|
||||
) : null}
|
||||
<section>
|
||||
<h3>
|
||||
{activity === "unavailable"
|
||||
? callState === "unavailable"
|
||||
? "暂时无法读取调用记录"
|
||||
: "暂时无法读取记忆状态"
|
||||
: activity === "call" && recentCall
|
||||
? callDetailHeadline(
|
||||
recentCall,
|
||||
recentCallHost?.display_name ??
|
||||
callHostName(recentCall.host_id),
|
||||
)
|
||||
: activity === "memory" && latest
|
||||
? `${latest.content}${latest.truncated ? "…" : ""}`
|
||||
: activity === "dialogue" && latestDialogue
|
||||
? `${latestDialogue.excerpt}${latestDialogue.truncated ? "…" : ""}`
|
||||
: activity === "importing"
|
||||
? "对话持续导入中,即将沉淀你的记忆"
|
||||
: activity === "first-running"
|
||||
? "正在沉淀第一批记忆"
|
||||
: activity === "first-queued"
|
||||
? "准备沉淀第一批记忆"
|
||||
: activity === "loading"
|
||||
? "正在读取动态"
|
||||
: "还没有已生成的记忆"}
|
||||
</h3>
|
||||
<small>
|
||||
{activity === "unavailable"
|
||||
? callState === "unavailable"
|
||||
? "已有记忆仍可查看;重试不会发起新的调用。"
|
||||
: "读取失败不会当作没有记忆。"
|
||||
: activity === "call" && recentCall
|
||||
? `${stamped(recentCall.occurred_at)} · ${callRowDetail(recentCall)}${callState === "incomplete" ? " · 有几次调用没被记下来,列表可能不全" : ""}`
|
||||
: activity === "memory" && latest
|
||||
? `${time} · ${memoryAction(latest.action)} · ${latest.source_ids.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join("、")} · 版本 ${latest.revision}`
|
||||
: activity === "dialogue" && latestDialogue
|
||||
? `${clockParts(latestDialogue.created_at ?? latestDialogue.imported_at)?.clock ?? "时间未知"} ${latestDialogue.time_basis === "conversation" ? "对话时间" : "导入时间"} · ${imports.sources.find((source) => source.source_id === latestDialogue.source_id)?.display_name ?? latestDialogue.source_id}`
|
||||
: activity === "importing"
|
||||
? "对话正在后台持续保存。"
|
||||
: "历史已保存,记忆生成后会显示在这里。"}
|
||||
</small>
|
||||
</section>
|
||||
{(activity === "unavailable" ||
|
||||
(activity === "call" && CALL_HISTORY_ENTRY_VISIBLE) ||
|
||||
activity === "memory") && (
|
||||
<button
|
||||
type="button"
|
||||
className="r6-home-activity-link"
|
||||
onClick={
|
||||
activity === "unavailable"
|
||||
? () => {
|
||||
setRevision((n) => n + 1);
|
||||
setCallRevision((n) => n + 1);
|
||||
}
|
||||
{activity === "unavailable"
|
||||
? "重新读取"
|
||||
: activity === "call"
|
||||
? onCalls
|
||||
: () => onMemories(latest?.memory_id)
|
||||
}
|
||||
>
|
||||
{activity === "unavailable"
|
||||
? "重新读取"
|
||||
: activity === "call"
|
||||
? "查看记录"
|
||||
: "查看记忆"}
|
||||
{activity === "unavailable" ? null : (
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
? "查看记录"
|
||||
: "查看记忆"}
|
||||
{activity === "unavailable" ? null : (
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</HomeCardBoundary>
|
||||
{attention ? (
|
||||
<section className="r6-home-auto-failure" role="alert">
|
||||
@@ -1038,81 +1038,81 @@ export function ApplicationsHome({
|
||||
</section>
|
||||
) : null}
|
||||
<HomeCardBoundary label="运行状态">
|
||||
<aside className="r6-home-runtime">
|
||||
<h2>运行状态</h2>
|
||||
<h3>
|
||||
{connected
|
||||
? "后台运行中"
|
||||
: imports.healthStale
|
||||
? "连接状态待核对"
|
||||
: "服务未连接"}
|
||||
</h3>
|
||||
<p>仅处理已授权范围</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>对话同步</dt>
|
||||
<dd>{syncState}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>记忆整理</dt>
|
||||
<dd>{stateLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>应用访问</dt>
|
||||
<dd>{accessText}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{accessUnknownText ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="应用接入核对"
|
||||
className="r6-home-auto-failure"
|
||||
>
|
||||
<p>{accessUnknownText}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="r6-home-activity-link"
|
||||
disabled={hostQuery.isFetching}
|
||||
onClick={() => void hostQuery.refetch()}
|
||||
<aside className="r6-home-runtime">
|
||||
<h2>运行状态</h2>
|
||||
<h3>
|
||||
{connected
|
||||
? "后台运行中"
|
||||
: imports.healthStale
|
||||
? "连接状态待核对"
|
||||
: "服务未连接"}
|
||||
</h3>
|
||||
<p>仅处理已授权范围</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>对话同步</dt>
|
||||
<dd>{syncState}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>记忆整理</dt>
|
||||
<dd>{stateLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>应用访问</dt>
|
||||
<dd>{accessText}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{accessUnknownText ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="应用接入核对"
|
||||
className="r6-home-auto-failure"
|
||||
>
|
||||
{hostQuery.isFetching ? "核对中…" : "重新核对"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{hostStale ? (
|
||||
<p
|
||||
role="status"
|
||||
aria-label="应用接入状态"
|
||||
className="r6-home-auto-failure"
|
||||
>
|
||||
应用接入状态暂时读取失败,当前为上次状态。
|
||||
<p>{accessUnknownText}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="r6-home-activity-link"
|
||||
disabled={hostQuery.isFetching}
|
||||
onClick={() => void hostQuery.refetch()}
|
||||
>
|
||||
{hostQuery.isFetching ? "核对中…" : "重新核对"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{hostStale ? (
|
||||
<p
|
||||
role="status"
|
||||
aria-label="应用接入状态"
|
||||
className="r6-home-auto-failure"
|
||||
>
|
||||
应用接入状态暂时读取失败,当前为上次状态。
|
||||
</p>
|
||||
) : null}
|
||||
{autoFailureText ? (
|
||||
<div role="alert" className="r6-home-auto-failure">
|
||||
<p>{`${autoFailureText}不会自动重试。`}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="r6-home-activity-link"
|
||||
disabled={auto.running.length > 0}
|
||||
onClick={() =>
|
||||
void retryAutoReconcile({
|
||||
sources: imports.sources,
|
||||
setSync: imports.setSync,
|
||||
refresh: imports.refresh,
|
||||
})
|
||||
}
|
||||
>
|
||||
重新接入
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="r6-home-checked">
|
||||
{!checked
|
||||
? "上次核对:待核对"
|
||||
: `上次核对:${checked.today ? "今天 " : ""}${checked.clock}`}
|
||||
</p>
|
||||
) : null}
|
||||
{autoFailureText ? (
|
||||
<div role="alert" className="r6-home-auto-failure">
|
||||
<p>{`${autoFailureText}不会自动重试。`}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="r6-home-activity-link"
|
||||
disabled={auto.running.length > 0}
|
||||
onClick={() =>
|
||||
void retryAutoReconcile({
|
||||
sources: imports.sources,
|
||||
setSync: imports.setSync,
|
||||
refresh: imports.refresh,
|
||||
})
|
||||
}
|
||||
>
|
||||
重新接入
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="r6-home-checked">
|
||||
{!checked
|
||||
? "上次核对:待核对"
|
||||
: `上次核对:${checked.today ? "今天 " : ""}${checked.clock}`}
|
||||
</p>
|
||||
</aside>
|
||||
</aside>
|
||||
</HomeCardBoundary>
|
||||
</div>
|
||||
<section
|
||||
|
||||
@@ -439,7 +439,7 @@ it.each([
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "未记录 · 09/20 11:03",
|
||||
name: /^未记录 · 09\/20 \d{2}:03$/,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("没有找到相关内容")).toBeTruthy();
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
type CallHistoryPage,
|
||||
callDeliveryLabel,
|
||||
callDetailHeadline,
|
||||
callReturnDeliveryLabel,
|
||||
callRowDetail,
|
||||
callRowHeadline,
|
||||
callReturnDeliveryLabel,
|
||||
clearAllCallEvents,
|
||||
clearCallEvents,
|
||||
getCallHistory,
|
||||
@@ -122,7 +122,9 @@ function CallReferences({
|
||||
? "待核对"
|
||||
: "不可用"}
|
||||
</h4>
|
||||
{reference.source_id && <p>{sourceLabel(reference.source_id)}</p>}
|
||||
{reference.source_id && (
|
||||
<p>{sourceLabel(reference.source_id)}</p>
|
||||
)}
|
||||
</div>
|
||||
{current?.available && current.reason === null && (
|
||||
<R6Button
|
||||
@@ -511,8 +513,9 @@ export function CallHistory({
|
||||
{stateLabels[selected.state]}
|
||||
</p>
|
||||
<p className="call-history-stage">
|
||||
找到原文 {selected.raw_count} 段 · 记忆 {selected.memory_count} 条
|
||||
· 用时 {(selected.elapsed_ms / 1000).toFixed(1)} 秒
|
||||
找到原文 {selected.raw_count} 段 · 记忆{" "}
|
||||
{selected.memory_count} 条 · 用时{" "}
|
||||
{(selected.elapsed_ms / 1000).toFixed(1)} 秒
|
||||
</p>
|
||||
{callDeliveryLabel(selected) && (
|
||||
<p className="call-history-stage">
|
||||
@@ -592,9 +595,7 @@ export function CallHistory({
|
||||
{callRowHeadline(item)}
|
||||
</button>
|
||||
</h2>
|
||||
<p>
|
||||
{callRowDetail(item)}
|
||||
</p>
|
||||
<p>{callRowDetail(item)}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -168,7 +168,9 @@ describe("HealthPanel", () => {
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false");
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(setDetailedLogging).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("true"));
|
||||
await waitFor(() =>
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("true"),
|
||||
);
|
||||
});
|
||||
|
||||
it("records a problem marker, copies its id and exports diagnostics", async () => {
|
||||
|
||||
@@ -353,9 +353,7 @@ export function HistoryImport({
|
||||
await reviewSource(next, token);
|
||||
settle("succeeded");
|
||||
} catch (reason) {
|
||||
settle(
|
||||
saved ? "unknown" : apiErrorCode(reason) ? "failed" : "unknown",
|
||||
);
|
||||
settle(saved ? "unknown" : apiErrorCode(reason) ? "failed" : "unknown");
|
||||
if (active(token))
|
||||
setError(
|
||||
saved
|
||||
|
||||
@@ -36,7 +36,9 @@ export class HomeCardBoundary extends Component<Props, State> {
|
||||
if (this.state.crashed) {
|
||||
return (
|
||||
<section className="r6-home-auto-failure" role="status">
|
||||
<p>{this.props.label}暂时显示不了,其他功能不受影响,稍后会自动恢复。</p>
|
||||
<p>
|
||||
{this.props.label}暂时显示不了,其他功能不受影响,稍后会自动恢复。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -246,10 +246,19 @@ export function ImportProgressStrip({
|
||||
const firstImport = !stale && runningHistory.length > 0;
|
||||
const actionable =
|
||||
currentArrival !== null &&
|
||||
["model-required", "consent-required", "failed", "stopped", "unavailable"].includes(
|
||||
currentArrival.phase,
|
||||
);
|
||||
if (!firstImport && !actionable && !failed.length && !completedWithErrors.length) {
|
||||
[
|
||||
"model-required",
|
||||
"consent-required",
|
||||
"failed",
|
||||
"stopped",
|
||||
"unavailable",
|
||||
].includes(currentArrival.phase);
|
||||
if (
|
||||
!firstImport &&
|
||||
!actionable &&
|
||||
!failed.length &&
|
||||
!completedWithErrors.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const imported = sources.reduce(
|
||||
@@ -264,14 +273,25 @@ export function ImportProgressStrip({
|
||||
? "模型授权已撤回,新的对话不会再整理成记忆。"
|
||||
: "有些内容没有处理完成,请前往设置查看。";
|
||||
return (
|
||||
<div className="import-strip" role="status" aria-live="polite" data-feedback="compact">
|
||||
<div
|
||||
className="import-strip"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-feedback="compact"
|
||||
>
|
||||
<div className="import-strip-headline">
|
||||
<div className="import-strip-heading">
|
||||
{firstImport && <span className="import-strip-pulse" aria-hidden="true" />}
|
||||
{firstImport && (
|
||||
<span className="import-strip-pulse" aria-hidden="true" />
|
||||
)}
|
||||
<strong>{message}</strong>
|
||||
</div>
|
||||
{!firstImport && onConfigureModel && (
|
||||
<button type="button" className="secondary" onClick={onConfigureModel}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onConfigureModel}
|
||||
>
|
||||
<Settings2 size={15} aria-hidden="true" />
|
||||
去设置
|
||||
</button>
|
||||
|
||||
@@ -50,9 +50,7 @@ it("OBS-18: reads the configured model before saying it is not configured", asyn
|
||||
page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
expect(screen.getByText(/整理模型:/).textContent).toContain("正在读取");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "配置模型" }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "配置模型" })).toBeNull();
|
||||
release([
|
||||
{
|
||||
profile_id: "p1",
|
||||
|
||||
@@ -706,9 +706,7 @@ describe("MemorySpace", () => {
|
||||
// The arrival status belongs to the workbench surface, not the ask tab.
|
||||
await userEvent.click(screen.getByRole("tab", { name: "工作台" }));
|
||||
expect(screen.getByRole("region", { name: "记忆整理状态" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("等待允许模型读取本地会话内容"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("等待允许模型读取本地会话内容")).toBeTruthy();
|
||||
expect(screen.queryByTestId("memory-graph")).toBeNull();
|
||||
expectNoTeachingWrites();
|
||||
});
|
||||
|
||||
@@ -284,11 +284,7 @@ export function MemorySpace({
|
||||
null,
|
||||
);
|
||||
const [arrivalSetup, setArrivalSetup] = useState<
|
||||
| "checking"
|
||||
| "model-required"
|
||||
| "consent-required"
|
||||
| "ready"
|
||||
| "unavailable"
|
||||
"checking" | "model-required" | "consent-required" | "ready" | "unavailable"
|
||||
>("checking");
|
||||
const [checkingSetup, setCheckingSetup] = useState(false);
|
||||
const liveLoading = useRef(false);
|
||||
@@ -318,7 +314,10 @@ export function MemorySpace({
|
||||
]);
|
||||
if (stopped || version !== request) return;
|
||||
setCheckingSetup(false);
|
||||
if (profiles.status !== "fulfilled" || permission.status !== "fulfilled") {
|
||||
if (
|
||||
profiles.status !== "fulfilled" ||
|
||||
permission.status !== "fulfilled"
|
||||
) {
|
||||
setArrivalSetup("unavailable");
|
||||
return;
|
||||
}
|
||||
@@ -351,6 +350,8 @@ export function MemorySpace({
|
||||
// none"; not having read it yet means we do not know, and saying the first
|
||||
// when we mean the second is how 483 memories came to look like zero.
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const loadedRef = useRef(loaded);
|
||||
loadedRef.current = loaded;
|
||||
/** Focus targets for the mode tablist's roving arrow-key navigation. */
|
||||
const modeTabs = useRef<
|
||||
Partial<
|
||||
@@ -766,7 +767,7 @@ export function MemorySpace({
|
||||
async function attemptLoad() {
|
||||
if (cancelled || loading) return;
|
||||
loading = true;
|
||||
if (loaded) {
|
||||
if (loadedRef.current) {
|
||||
refreshingTimer = window.setTimeout(() => {
|
||||
if (!cancelled) setRefreshing(true);
|
||||
}, 2_000);
|
||||
@@ -777,7 +778,7 @@ export function MemorySpace({
|
||||
if (!cancelled) {
|
||||
// A failed refresh keeps the list on screen, so it says why the list
|
||||
// may be behind. A quick successful one never flashes the hint.
|
||||
setRefreshing(!success && loaded);
|
||||
setRefreshing(!success && loadedRef.current);
|
||||
}
|
||||
if (cancelled || success) return;
|
||||
attempt += 1;
|
||||
|
||||
@@ -15,10 +15,14 @@ vi.mock("./ui-trace", () => ({
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.getModelContentPermission).mockResolvedValue({ allowed: false });
|
||||
vi.mocked(api.setModelContentPermission).mockImplementation(async (allowed) => ({
|
||||
allowed,
|
||||
}));
|
||||
vi.mocked(api.getModelContentPermission).mockResolvedValue({
|
||||
allowed: false,
|
||||
});
|
||||
vi.mocked(api.setModelContentPermission).mockImplementation(
|
||||
async (allowed) => ({
|
||||
allowed,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -46,12 +50,17 @@ it("writes the desired boolean and updates the switch", async () => {
|
||||
await user.click(toggle);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.setModelContentPermission).toHaveBeenCalledWith(true, "trace-id"),
|
||||
expect(api.setModelContentPermission).toHaveBeenCalledWith(
|
||||
true,
|
||||
"trace-id",
|
||||
),
|
||||
);
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("states that generated-memory search is independent", async () => {
|
||||
render(<ModelContentConsent onClose={vi.fn()} />);
|
||||
expect(await screen.findByText(/已生成记忆的搜索和读取不受影响/)).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(/已生成记忆的搜索和读取不受影响/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
@@ -19,9 +19,7 @@ export function ModelContentConsent({
|
||||
|
||||
useEffect(() => {
|
||||
const before = document.activeElement;
|
||||
panel.current
|
||||
?.querySelector<HTMLElement>("button:not(:disabled)")
|
||||
?.focus();
|
||||
panel.current?.querySelector<HTMLElement>("button:not(:disabled)")?.focus();
|
||||
return () => {
|
||||
if (before instanceof HTMLElement && before.isConnected) before.focus();
|
||||
};
|
||||
|
||||
@@ -800,9 +800,7 @@ describe("ModelSettings", () => {
|
||||
profiles = [preset];
|
||||
render(<ModelSettings />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("region", { name: "预配置模型服务" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("region", { name: "预配置模型服务" })).toBeTruthy();
|
||||
// 贴一个 key 就能用的时候,没理由让人先面对协议、Base URL 和超时。
|
||||
expect(screen.queryByLabelText("协议")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "自定义一个模型" })).toBeTruthy();
|
||||
@@ -862,10 +860,7 @@ describe("ModelSettings", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSettings />);
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText("粘贴 API Key"),
|
||||
"preset-key",
|
||||
);
|
||||
await user.type(screen.getByLabelText("粘贴 API Key"), "preset-key");
|
||||
await user.click(screen.getByRole("button", { name: "保存 key" }));
|
||||
|
||||
expect(update).toHaveBeenCalledWith("preset-1", {
|
||||
|
||||
@@ -648,9 +648,7 @@ export function ModelSettings({
|
||||
}
|
||||
} catch {
|
||||
if (alive.current) {
|
||||
setSaveError(
|
||||
"配置尚未保存成功,已填写内容保留,可核对后重试。",
|
||||
);
|
||||
setSaveError("配置尚未保存成功,已填写内容保留,可核对后重试。");
|
||||
}
|
||||
} finally {
|
||||
flight.current = false;
|
||||
@@ -826,14 +824,13 @@ export function ModelSettings({
|
||||
<div className="model-saved-heading">
|
||||
<Check size={18} aria-hidden="true" />
|
||||
<strong>
|
||||
{isReady(savedProfile) &&
|
||||
savedProfile.is_active
|
||||
{isReady(savedProfile) && savedProfile.is_active
|
||||
? "已保存,已启用"
|
||||
: isReady(savedProfile)
|
||||
? "已保存,连接通过"
|
||||
: models.probeFor(savedProfile)
|
||||
? "已保存,测试未通过"
|
||||
: "已保存,待测试"}
|
||||
? "已保存,连接通过"
|
||||
: models.probeFor(savedProfile)
|
||||
? "已保存,测试未通过"
|
||||
: "已保存,待测试"}
|
||||
</strong>
|
||||
</div>
|
||||
<h3>{savedProfile.name}</h3>
|
||||
@@ -917,7 +914,7 @@ export function ModelSettings({
|
||||
刷新配置列表
|
||||
</button>
|
||||
)}
|
||||
<label>
|
||||
<div className="model-form-field">
|
||||
服务商预设
|
||||
<OptionMenu
|
||||
ariaLabel="服务商预设"
|
||||
@@ -954,7 +951,7 @@ export function ModelSettings({
|
||||
只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。
|
||||
请对照服务商的模型列表核对后再保存。
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
协议
|
||||
<select
|
||||
@@ -1245,11 +1242,7 @@ export function ModelSettings({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
locked ||
|
||||
editChanged ||
|
||||
(!editing && unknownCreation)
|
||||
}
|
||||
disabled={locked || editChanged || (!editing && unknownCreation)}
|
||||
onClick={() => void persist(true)}
|
||||
>
|
||||
{setupPhase === "testing"
|
||||
@@ -1350,10 +1343,7 @@ export function ModelSettings({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
locked ||
|
||||
!isReady(profile)
|
||||
}
|
||||
disabled={locked || !isReady(profile)}
|
||||
onClick={() => models.activate(profile.profile_id)}
|
||||
>
|
||||
设为当前模型
|
||||
|
||||
@@ -332,9 +332,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
connectForOnboarding: vi.fn(
|
||||
() =>
|
||||
new Promise<
|
||||
Awaited<
|
||||
ReturnType<ImportSourcesController["connectForOnboarding"]>
|
||||
>
|
||||
Awaited<ReturnType<ImportSourcesController["connectForOnboarding"]>>
|
||||
>((_resolve, reject) => {
|
||||
rejectImport = reject;
|
||||
}),
|
||||
@@ -363,9 +361,9 @@ describe("OnboardingFlow S1.3", () => {
|
||||
});
|
||||
|
||||
await act(async () => rejectImport(new Error("response lost")));
|
||||
expect(
|
||||
screen.getByRole("region", { name: "模型步骤边界" }),
|
||||
).toBe(modelBoundary);
|
||||
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBe(
|
||||
modelBoundary,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
|
||||
@@ -188,6 +188,7 @@ export function OnboardingFlow({
|
||||
accepted,
|
||||
startingImport,
|
||||
imports.sources,
|
||||
modelSourceIds,
|
||||
state.selected_source_ids,
|
||||
]);
|
||||
useEffect(() => {
|
||||
@@ -431,7 +432,6 @@ export function OnboardingFlow({
|
||||
await persistFinish(destination);
|
||||
flight.current = false;
|
||||
if (alive.current) setBusy(null);
|
||||
|
||||
},
|
||||
[persistFinish],
|
||||
);
|
||||
@@ -616,8 +616,7 @@ export function OnboardingFlow({
|
||||
: imports.sources;
|
||||
const defaultDraft = defaultConnectionDraft(reviewSources, sourceIds);
|
||||
const activeDraft =
|
||||
connectionTouched ||
|
||||
connectionDraft.hostSourceIds.length > 0
|
||||
connectionTouched || connectionDraft.hostSourceIds.length > 0
|
||||
? connectionDraft
|
||||
: defaultDraft;
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
ModelProbeResult,
|
||||
ModelProfile,
|
||||
ModelProfileInput,
|
||||
ModelProbeResult,
|
||||
} from "@one-memory/contracts";
|
||||
import { useRef, useState } from "react";
|
||||
import { getModelProfiles, setModelContentPermission } from "./api";
|
||||
|
||||
@@ -251,19 +251,12 @@ it("discloses that testing does not send historical conversations", () => {
|
||||
|
||||
expect(screen.getByText(/测试连接只发送测试内容/)).toBeTruthy();
|
||||
expect(screen.getByText(/已导入 37 条对话/)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/启用时会打开全局模型内容权限/),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/启用时会打开全局模型内容权限/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not expose an extra backlog selector in the first-run model page", () => {
|
||||
const p = props();
|
||||
render(
|
||||
<OnboardingModelStep
|
||||
{...p}
|
||||
capability={{ saveAndEnable: true }}
|
||||
/>,
|
||||
);
|
||||
render(<OnboardingModelStep {...p} capability={{ saveAndEnable: true }} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
|
||||
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/先测试模型连通性/)).toBeTruthy();
|
||||
|
||||
@@ -70,9 +70,7 @@ it("OBS-21: names a manually added memory 手动添加, not the internal source
|
||||
detail={{
|
||||
...detail,
|
||||
memory: { ...detail.memory, origin: "manual" },
|
||||
evidence: [
|
||||
{ ...detail.evidence[0], source_id: "one-memory-manual" },
|
||||
],
|
||||
evidence: [{ ...detail.evidence[0], source_id: "one-memory-manual" }],
|
||||
}}
|
||||
sources={[]}
|
||||
onChanged={vi.fn()}
|
||||
@@ -416,7 +414,9 @@ it("reports a definite undo refusal as not submitted, not as an unknown write",
|
||||
fireEvent.click(screen.getByText("更多管理"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "撤销上次修改" }));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("撤销未提交");
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"撤销未提交",
|
||||
);
|
||||
expect(screen.queryByText(/操作尚未确认/)).toBeNull();
|
||||
expect(trace.done).toHaveBeenLastCalledWith("failed", {
|
||||
reason: "memory_undo_failed",
|
||||
|
||||
@@ -173,9 +173,7 @@ export function SelectedMemoryDetail({
|
||||
} catch (reason) {
|
||||
const refused = isDefiniteRefusal(reason);
|
||||
trace.done(refused ? "failed" : "unknown", {
|
||||
reason: refused
|
||||
? "memory_delete_failed"
|
||||
: "memory_delete_unconfirmed",
|
||||
reason: refused ? "memory_delete_failed" : "memory_delete_unconfirmed",
|
||||
});
|
||||
if (alive.current)
|
||||
setError(
|
||||
@@ -445,8 +443,7 @@ export function SelectedMemoryDetail({
|
||||
{detail.evidence.map((item) => (
|
||||
<section key={item.evidence_id}>
|
||||
<small>
|
||||
{sourceNameOr(sources, item.source_id, "来源待核对")}{" "}
|
||||
·{" "}
|
||||
{sourceNameOr(sources, item.source_id, "来源待核对")} ·{" "}
|
||||
{item.written_by === "person"
|
||||
? "你"
|
||||
: item.written_by === "agent"
|
||||
|
||||