diff --git a/.github/workflows/e2e-harness.yml b/.github/workflows/e2e-harness.yml index 7983def467..60bc7b88c8 100644 --- a/.github/workflows/e2e-harness.yml +++ b/.github/workflows/e2e-harness.yml @@ -163,6 +163,127 @@ jobs: if: always() run: docker rm --force recall-effort-oceanbase || true + atomic-memory-clean-oceanbase: + name: Atomic Memory clean migration (OceanBase) + if: github.event_name != 'workflow_dispatch' || inputs.suite != 'recall-effort' + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + OCEANBASE_IMAGE: ghcr.io/oceanbase/oceanbase-ce:4.3.5.6-106000012026040916 + POWERCONTEXT_TEST_MIGRATION_OCEANBASE_URL: >- + mysql+aoceanbase://root%40test:powercontext-e2e@127.0.0.1:2881/powercontext_test?charset=utf8mb4 + PYTHONUNBUFFERED: "1" + MIGRATION_REPORTS: ${{ github.workspace }}/.powercontext/atomic-memory-clean-oceanbase + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + with: + python-version: "3.12" + + - name: Start the isolated OceanBase test instance + run: | + docker run --detach --name atomic-memory-clean-oceanbase \ + --publish 127.0.0.1:2881:2881 \ + --ulimit nofile=65536:65536 \ + --env MODE=slim \ + --env OB_DATABASE=powercontext_test \ + --env OB_TENANT_PASSWORD=powercontext-e2e \ + "$OCEANBASE_IMAGE" + for attempt in $(seq 1 120); do + if docker exec atomic-memory-clean-oceanbase \ + obclient -h127.0.0.1 -P2881 -uroot@test -ppowercontext-e2e \ + -Dpowercontext_test -e 'SELECT 1' >/dev/null 2>&1; then + # The slim image's default 1 GB maximum is too small for the + # indexes created across the clean migration acceptance cases. + docker exec atomic-memory-clean-oceanbase \ + obclient -h127.0.0.1 -P2881 -uroot@sys \ + -e "ALTER SYSTEM SET datafile_maxsize='8G'; ALTER SYSTEM SET datafile_next='512M'; ALTER SYSTEM SET datafile_size='4G';" + datafile_ready=false + for config_attempt in $(seq 1 30); do + docker exec atomic-memory-clean-oceanbase \ + obclient -h127.0.0.1 -P2881 -uroot@sys --batch --skip-column-names \ + -e "SHOW PARAMETERS WHERE name IN ('datafile_size', 'datafile_maxsize', 'datafile_next');" \ + > "$RUNNER_TEMP/atomic-memory-datafile.tsv" + if awk ' + $5 == "datafile_size" && $7 == "4G" { size = 1 } + $5 == "datafile_maxsize" && $7 == "8G" { maximum = 1 } + $5 == "datafile_next" && $7 == "512M" { increment = 1 } + END { exit !(size && maximum && increment) } + ' "$RUNNER_TEMP/atomic-memory-datafile.tsv"; then + datafile_ready=true + break + fi + sleep 2 + done + cat "$RUNNER_TEMP/atomic-memory-datafile.tsv" + if [ "$datafile_ready" != true ]; then + echo 'OceanBase datafile configuration did not become effective within one minute.' >&2 + exit 1 + fi + docker exec atomic-memory-clean-oceanbase \ + obclient -h127.0.0.1 -P2881 -uroot@test -ppowercontext-e2e \ + -Dpowercontext_test \ + -e "SET GLOBAL recyclebin=OFF; SELECT VERSION(); SHOW VARIABLES LIKE 'ob_compatibility_mode'; SHOW VARIABLES LIKE 'recyclebin';" + exit 0 + fi + if [ "$(docker inspect --format '{{.State.Running}}' atomic-memory-clean-oceanbase)" != true ]; then + echo 'OceanBase exited before becoming ready.' >&2 + exit 1 + fi + sleep 5 + done + echo 'OceanBase did not become ready within 10 minutes.' >&2 + exit 1 + + - name: Run clean migration evidence, grants and vectors without skips + timeout-minutes: 45 + run: | + uv run --locked python scripts/run_atomic_memory_clean_migration.py \ + oceanbase --output "$MIGRATION_REPORTS" + + - name: Collect the database log + if: always() + run: | + mkdir -p "$MIGRATION_REPORTS" + docker logs atomic-memory-clean-oceanbase > "$MIGRATION_REPORTS/oceanbase.log" 2>&1 || true + for logfile in observer.log observer.log.wf; do + docker exec atomic-memory-clean-oceanbase tail -n 20000 "/root/demo/log/$logfile" \ + > "$MIGRATION_REPORTS/$logfile" 2>&1 || true + done + docker inspect --format 'MemoryBytes={{.HostConfig.Memory}} NanoCpus={{.HostConfig.NanoCpus}} CpuQuota={{.HostConfig.CpuQuota}}' \ + atomic-memory-clean-oceanbase > "$MIGRATION_REPORTS/container-resources.txt" 2>&1 || true + docker stats --no-stream atomic-memory-clean-oceanbase \ + > "$MIGRATION_REPORTS/container-stats.txt" 2>&1 || true + docker exec atomic-memory-clean-oceanbase \ + obclient -h127.0.0.1 -P2881 -uroot@sys \ + -e "SHOW PARAMETERS WHERE name IN ('datafile_size', 'datafile_maxsize', 'datafile_next', 'memory_limit', 'system_memory', 'cpu_count');" \ + > "$MIGRATION_REPORTS/database-parameters.txt" 2>&1 || true + + - name: Save complete observer logs on failure + if: failure() + run: | + mkdir -p "$MIGRATION_REPORTS/observer" + docker cp atomic-memory-clean-oceanbase:/root/demo/log/. "$MIGRATION_REPORTS/observer/" || true + + - name: Save clean migration reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: atomic-memory-clean-oceanbase-${{ github.sha }} + path: ${{ env.MIGRATION_REPORTS }}/ + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + + - name: Stop and remove the test instance + if: always() + run: docker rm --force atomic-memory-clean-oceanbase || true + acceptance: needs: validate runs-on: ubuntu-latest @@ -184,6 +305,57 @@ jobs: with: version: ${{ env.UV_VERSION }} + - name: Verify OceanBase Atomic Memory and HTTP regressions + if: matrix.database == 'oceanbase' + env: + COMPOSE_PROJECT_NAME: powercontext-e2e-oceanbase + POWERCONTEXT_E2E_OUTPUT: ${{ github.workspace }}/.powercontext-e2e/oceanbase/acceptance + run: | + mkdir -p "$POWERCONTEXT_E2E_OUTPUT" + compose=(docker compose -f e2e/bub/compose.yaml -f e2e/bub/compose.oceanbase.yaml) + "${compose[@]}" up --detach --wait --wait-timeout 660 oceanbase + container_id=$("${compose[@]}" ps -q oceanbase) + oceanbase_ip=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container_id") + test -n "$oceanbase_ip" + export POWERCONTEXT_TEST_OCEANBASE_URL="mysql+aoceanbase://root%40test:powercontext-e2e@${oceanbase_ip}:2881/powercontext?charset=utf8mb4" + uv run --locked --extra server python - <<'PY' + import asyncio + import os + + from pydantic import SecretStr + + from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile + + async def version(): + config = OceanBaseConfig(url=SecretStr(os.environ["POWERCONTEXT_TEST_OCEANBASE_URL"])) + async with OceanBaseProfile.open(config, tables=()) as profile: + async with profile.database.transaction() as connection: + result = await connection.exec_driver_sql("SELECT VERSION()") + print("OceanBase engine:", result.scalar_one()) + + asyncio.run(version()) + PY + uv run --locked --extra server python -m pytest -vv -ra \ + tests/e2e/test_atomic_memory_search_authorization_snapshot.py \ + tests/e2e/test_atomic_memory_read_snapshot.py \ + tests/e2e/test_runtime_server.py -k oceanbase \ + --junitxml="$RUNNER_TEMP/atomic-memory-oceanbase-snapshots.xml" + uv run --locked --extra server python - <<'PY' + import json + import os + import xml.etree.ElementTree as ET + from pathlib import Path + + suites = ET.parse(Path(os.environ["RUNNER_TEMP"]) / "atomic-memory-oceanbase-snapshots.xml").iter("testsuite") + counts = {key: 0 for key in ("tests", "failures", "errors", "skipped")} + for suite in suites: + for key in counts: + counts[key] += int(suite.get(key, "0")) + print("OceanBase Atomic Memory and HTTP results:", json.dumps(counts)) + if not counts["tests"] or any(counts[key] for key in ("failures", "errors", "skipped")): + raise SystemExit("OceanBase Atomic Memory and HTTP regressions must execute without failures, errors, or skips") + PY + - name: Run deterministic scenarios id: acceptance_scenarios env: diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 072fe2c032..d0aa39a280 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -205,6 +205,56 @@ jobs: seekdb-native.log if-no-files-found: ignore + atomic-memory-clean-seekdb: + name: Atomic Memory clean migration (SeekDB) + timeout-minutes: 40 + runs-on: ubuntu-latest + env: + POWERCONTEXT_TEST_MIGRATION_SEEKDB: "1" + PYTHONUNBUFFERED: "1" + MIGRATION_REPORTS: ${{ github.workspace }}/.powercontext/atomic-memory-clean-seekdb + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + with: + python-version: "3.12" + + - name: Run clean migration evidence, grants and vectors without skips + timeout-minutes: 35 + env: + TMPDIR: ${{ runner.temp }}/am-seekdb + run: | + mkdir -p "$TMPDIR" + uv run --locked --extra seekdb python scripts/run_atomic_memory_clean_migration.py \ + seekdb --output "$MIGRATION_REPORTS" + + - name: Collect native diagnostics + if: always() + env: + TMPDIR: ${{ runner.temp }}/am-seekdb + run: | + mkdir -p "$MIGRATION_REPORTS" + df -h . /tmp > "$MIGRATION_REPORTS/disk.txt" + if [ -d "$TMPDIR" ]; then + find "$TMPDIR" -type f \( -name '*.log' -o -name '*.log.wf' \) \ + -print -exec tail -c 8388608 {} \; > "$MIGRATION_REPORTS/seekdb-native.log" + fi + + - name: Save clean migration reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: atomic-memory-clean-seekdb-${{ github.sha }} + path: ${{ env.MIGRATION_REPORTS }}/ + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + windows-unit-portability: timeout-minutes: 20 runs-on: windows-latest diff --git a/desktop/README.md b/desktop/README.md index 7d0dc741e5..ee721e6e97 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -59,9 +59,9 @@ Profiles persist under the app data directory; credentials never appear in profi After explicitly activating a qualified connection and choosing a Scope, use Home's note form or **My memories → Add note**. The target connection and exact Scope are shown before submission. Enter inserts a newline; only the save button submits. The input is plain text, limited conservatively to 8192 raw UTF-8 bytes without truncation. The Server owns normalization and the returned text is authoritative. -Search uses FTS in the selected Scope and returns at most 10 matches. This is not a full directory or history, and ten matches do not establish a total count. **Read exact version** sends the complete returned citation; it never substitutes the latest version. Copy buttons explicitly copy either the full plain text or citation JSON. +Search uses FTS in the selected Scope and returns at most 10 matches. This is not a full directory or history, and ten matches do not establish a total count. **Read exact version** reads the returned Atomic Memory reference (`{family: "atomic-memory", artifact_id, revision}`) at that exact revision; it never substitutes the latest version. Copy buttons explicitly copy either the full plain text or the reference JSON. -A successful save without an entry is reported as an operation success without inventing a citation. A timeout or interrupted dispatched write is **unknown**, not a safe invitation to retry: inspect the original Server/Scope before deciding whether to submit again. Identical text alone cannot identify that operation. Desktop does not automatically replay writes or keep an offline queue. Switching context hides old results while retaining minimal original-operation metadata for this session. See [validation guide](VALIDATION.md) for tested behavior and qualification gaps. +A successful save that returns no memory record is reported as an operation success without inventing a reference. A timeout or interrupted dispatched write is **unknown**, not a safe invitation to retry: inspect the original Server/Scope before deciding whether to submit again. Identical text alone cannot identify that operation. Desktop does not automatically replay writes or keep an offline queue. Switching context hides old results while retaining minimal original-operation metadata for this session. See [validation guide](VALIDATION.md) for tested behavior and qualification gaps. ## Contracts and resources @@ -104,7 +104,7 @@ Version verification has a 15-second deadline, service status 20 seconds, and in ## Remote installed-package acceptance -Windows GitHub Actions builds an unsigned installer, installs into a temporary Chinese path, and uses a matching Microsoft-signed WebDriver to operate the actual installed WebView2 page. An independent SQLite Server with synthetic data supports explicit connection activation, exact Scope selection, multiline Chinese note save, FTS search, exact reading, and paste-back verification of copied text and citation. The fixture Server and its temporary workspace are cleaned up afterward. +Windows GitHub Actions builds an unsigned installer, installs into a temporary Chinese path, and uses a matching Microsoft-signed WebDriver to operate the actual installed WebView2 page. An independent SQLite Server with synthetic data supports explicit connection activation, exact Scope selection, multiline Chinese note save, FTS search, exact reading, and paste-back verification of copied text and reference. The fixture Server and its temporary workspace are cleaned up afterward. Reports, a screenshot and driver logs accompany the installer in the `desktop-windows-internal-unsigned` artifact. Failed or pending steps are not acceptance passes. The UI script permits only GitHub Windows runners and does not operate your local desktop. Hosted runners do not establish clean standard-user Windows 11, actual IME, screen-reader or Agent-host qualification. @@ -116,4 +116,4 @@ The CI-only lifecycle scenario forcibly ends its own installed Desktop process a Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget input, display zero and capped-ten search results, and verify cancel/confirm behavior when disconnecting with an unsaved draft. Each result requires its matching remote report. -After upgrading from the earlier preview, select `sqlite-6e237568-v1` and recheck your connection. The previous `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` selection is not silently upgraded to a different contract. See [current qualification](VALIDATION.md). +After upgrading from an earlier preview, select `sqlite-atomic-f702c041-v1` and recheck your connection. A previous `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` / `sqlite-6e237568-v1` / `sqlite-legacy-58f7f4f6-v1` selection is not silently upgraded to a different contract. See [current qualification](VALIDATION.md). diff --git a/desktop/README.zh.md b/desktop/README.zh.md index b25e78d593..fb1516ed41 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -78,9 +78,9 @@ Desktop 在每次诊断前检查固定路径、摘要,并用固定的 `--versi 明确启用合格连接并选择精确范围后,在首页填写笔记,或在「我的记忆」中点击「记一条」。提交前会显示目标连接和范围。Enter 只换行,点击保存才提交。正文是纯文本,保守限制为原始文本最多 8192 个 UTF-8 字节,不会截断;规范化由 Server 完成,以返回的正文为准。 -全文搜索只在当前范围执行,每次最多返回 10 条,不代表完整目录、历史或总数。阅读使用完整引用读取精确版本,不自动改读最新版本。复制按钮分别复制纯文本正文和引用 JSON。 +全文搜索只在当前范围执行,每次最多返回 10 条,不代表完整目录、历史或总数。阅读按返回的 Atomic Memory 引用(`{family: "atomic-memory", artifact_id, revision}`)读取该精确版本,不自动改读最新版本。复制按钮分别复制纯文本正文和引用 JSON。 -保存成功但没有返回 entry 时,只报告操作成功,不编造引用。请求发出后的超时或中断可能意味着「结果未知」:先核对原连接和范围,再决定是否重新提交;相同正文不能证明属于这次操作。应用不会自动重试写入,也没有离线队列。切换连接或范围后隐藏旧结果,本次会话只保留最小操作状态和原目标。测试范围与剩余验收见 [验证说明](VALIDATION.md)。 +保存成功但没有返回记忆记录时,只报告操作成功,不编造引用。请求发出后的超时或中断可能意味着「结果未知」:先核对原连接和范围,再决定是否重新提交;相同正文不能证明属于这次操作。应用不会自动重试写入,也没有离线队列。切换连接或范围后隐藏旧结果,本次会话只保留最小操作状态和原目标。测试范围与剩余验收见 [验证说明](VALIDATION.md)。 [验证说明](VALIDATION.md)列出可复现的检查方式和待完成的平台、产品验收项。 @@ -98,4 +98,4 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径 安装版边界测试覆盖 8192 字节 Unicode 正文、超限输入禁止提交、搜索无结果和最多 10 条的提示,以及未保存草稿时取消或确认断开连接的行为。是否通过以对应远程报告为准。 -从早期预览版升级后,请选择 `sqlite-6e237568-v1` 并重新检查连接。旧的 `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` 选择不会静默升级为另一份契约。当前验证范围见[验证说明](VALIDATION.md)。 +从早期预览版升级后,请选择 `sqlite-atomic-f702c041-v1` 并重新检查连接。旧的 `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` / `sqlite-6e237568-v1` / `sqlite-legacy-58f7f4f6-v1` 选择不会静默升级为另一份契约。当前验证范围见[验证说明](VALIDATION.md)。 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index aa5bb1c165..47f3ccdbd6 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -22,15 +22,19 @@ cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) identifies the qualified Server source, normalized OpenAPI contract digest and qualification-wheel digest for `sqlite-6e237568-v1`. The qualification wheel was built from checkout `ea30fd5c`; its backend sources, OpenAPI contract, project configuration and lock match the Server commit recorded in that manifest. CI builds have their own artifact identities. +[compatibility.json](src-tauri/src/connections/compatibility.json) identifies the qualified Server source, normalized OpenAPI contract digest and qualification-wheel digest for `sqlite-atomic-f702c041-v1`, described in [Atomic Memory protocol qualification](#atomic-memory-protocol-qualification). CI builds have their own artifact identities. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. -Selecting a compatibility profile does not attest the remote binary identity. Existing `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` selections require explicitly choosing the new profile and rechecking the connection. CLI diagnostics separately verify the registered executable path, digest and exact version. +Selecting a compatibility profile does not attest the remote binary identity. Existing `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` / `sqlite-6e237568-v1` / `sqlite-legacy-58f7f4f6-v1` selections require explicitly choosing the current profile and rechecking the connection. Desktop reads memories only through Atomic Memory references, so Servers that answer only with Memory entry citations are not qualified. CLI diagnostics separately verify the registered executable path, digest and exact version. -`sqlite-legacy-58f7f4f6-v1` identifies Server sources at `58f7f4f6cbadd698bac35b3fca019d9f48f55b81`, contract digest `9f4ba7b35ecd3b3639af4e648aff84b713a7f30b51d5a4c30c8809aeb8dc8024`, and qualification-wheel digest `7203cf1a98cb9dfd8631e142f7a0f5e74c39845da4fb7d862117a49fa1ef38af`. Its original ten Desktop operations and Legacy Memory protocol were exercised by the native `ConnectionManager` against the isolated built-wheel SQLite Server in all four harness modes: anonymous loopback, Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. These checks cover save/search/exact historical citation reads, identity changes, revocation, ambiguous writes without replay, and Scope pagination. The original `sqlite-6e237568-v1` record retains its identities and operations. Current qualification requires explicitly selecting a profile whose recorded contract matches the bundled Desktop contract and rechecking the connection. +### Atomic Memory protocol qualification -This qualification used macOS 26.2 ARM64, Rust 1.95.0 and Python 3.12.7. An external system Python launcher was used for native process creation because this environment can terminate native subprocesses before their first request. The wheel, native client, fixtures and business assertions were preserved. The real CLI's fixed version, service-status and integration commands were also exercised with an isolated home and PATH; their actual outputs passed the native projections. These checks establish the stated SQLite compatibility and CLI response handling. Windows registered-executable checks, credentials, process containment and installed-package acceptance require their actual Windows CI checks. No remote legacy deployment was tested in this qualification. +`sqlite-atomic-f702c041-v1` identifies Server sources at `f702c0418d69e3072e1ec2fa657e0df81e8e51b4`, contract digest `5d8bbad951234b05e3174f1b9113e0ffe066ac968abbe53ac2456b2375e406d3`, and qualification-wheel digest `4fe64b868a2e214316a088574d8d4023d9b9679ec42029e2d13502121e6aade9`. The wheel version is `1.2.1.dev78+g77dc6f73b.d20261009`. It was captured at HEAD `77dc6f73b86678221d34a14e2de61dade0ff9db3` with staged tree `d2e9d703877ea707d78191ce0c358112fd69f65f` and a dirty working tree. That tree became the recorded Server commit; all 460 runtime files in the wheel match its sources byte for byte. The runtime source-manifest digest is `e26a4c4581f32b855689e3e0db26b48239f2f6758ab594addc9efc047978390d` (sorted wheel runtime paths and content digests). The OpenAPI contract, project configuration and lock remained unchanged between capture and commit. CI builds have their own wheel identities. + +The native `ConnectionManager` exercised the actual isolated built-wheel SQLite Server in four modes: anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. All four passed on macOS 26.2 ARM64 with Rust 1.95.0 and Python 3.12.7. Checks covered Atomic Memory save/search/exact Artifact revision reads, identity changes, binding revocation, ambiguous writes without replay, Scope pagination and independent Server survival after client exit. The catalog records the ten operations exercised, including `get_artifact_revision`. The native harness candidate remains separate from the product catalog. + +These results establish the stated Server protocol compatibility. Windows credentials, process containment, registered-executable checks and installed-package acceptance for this Atomic profile require their actual Windows CI checks. This protocol record does not establish platform or release acceptance. ## CI fixture 的独立资格 diff --git a/desktop/scripts/generate.mjs b/desktop/scripts/generate.mjs index 89e7b29c00..01272cbd75 100644 --- a/desktop/scripts/generate.mjs +++ b/desktop/scripts/generate.mjs @@ -34,7 +34,7 @@ const wanted = [ "get_default_scope", "remember_memory", "search_memory", - "get_memory_entry", + "get_artifact_revision", ]; const operations = {}; for (const [path, item] of Object.entries(contract.paths)) { @@ -85,6 +85,7 @@ function rustType(schema) { case "array": return `Vec<${rustType(schema.items)}>`; case "object": + if (schema.additionalProperties === true) return "serde_json::Value"; if ( schema.additionalProperties && typeof schema.additionalProperties === "object" @@ -101,6 +102,7 @@ const rustModels = [...schemaNames] const schema = contract.components.schemas[name]; const derive = "#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]"; + if (schema.$ref) return `pub type ${name} = ${rustType(schema)};\n`; if (schema.enum) { const values = schema.enum.filter((v) => v !== null); const members = values.map( @@ -112,13 +114,25 @@ const rustModels = [...schemaNames] ); return `${derive}\npub enum ${name} {\n${members.join("\n")}\n}\n`; } + if (schema.oneOf && !schema.type) { + const members = schema.oneOf.map((variant) => { + if (!variant.$ref) throw new Error("Unsupported inline wire union"); + const type = rustType(variant); + return ` ${type}(${type}),`; + }); + return `${derive}\n#[serde(untagged)]\npub enum ${name} {\n${members.join("\n")}\n}\n`; + } if (schema.type !== "object") return `pub type ${name} = ${rustType(schema)};\n`; const fields = Object.entries(schema.properties).map(([field, value]) => { let type = rustType(value); if (value.nullable || !schema.required?.includes(field)) type = `Option<${type}>`; - return `${!schema.required?.includes(field) ? ' #[serde(skip_serializing_if = "Option::is_none")]\n #[ts(optional = nullable)]\n' : ""} pub r#${field}: ${type},`; + const opaque = + value.type === "object" && value.additionalProperties === true + ? ' #[ts(type = "Record")]\n' + : ""; + return `${!schema.required?.includes(field) ? ' #[serde(skip_serializing_if = "Option::is_none")]\n #[ts(optional = nullable)]\n' : ""}${opaque} pub r#${field}: ${type},`; }); return `${derive}\n#[serde(deny_unknown_fields)]\npub struct ${name} {\n${fields.join("\n")}\n}\n`; }) @@ -127,7 +141,12 @@ const rustDeclarations = `\n#[rustfmt::skip]\npub fn declarations(config: &ts_rs ...schemaNames, ] .sort() - .map((name) => ` <${name} as ts_rs::TS>::decl(config),`) + .map((name) => { + const schema = contract.components.schemas[name]; + return schema.$ref + ? ` format!("type ${name} = {};", <${rustType(schema)} as ts_rs::TS>::name(config)),` + : ` <${name} as ts_rs::TS>::decl(config),`; + }) .join("\n")}\n ]\n}\n`; const outputs = { "src-tauri/src/transport/wire.rs": diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index c58762dd9c..7bc3a15a92 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -37,7 +37,7 @@ fn main() { "local_diagnostics", "remember_memory", "search_memory", - "memory_entry", + "atomic_memory_entry", "cancel_memory_reads", "desktop_state", "save_profile", diff --git a/desktop/src-tauri/capabilities/main.json b/desktop/src-tauri/capabilities/main.json index 89e1f1db5f..668f40f201 100644 --- a/desktop/src-tauri/capabilities/main.json +++ b/desktop/src-tauri/capabilities/main.json @@ -10,7 +10,7 @@ "allow-local-diagnostics", "allow-remember-memory", "allow-search-memory", - "allow-memory-entry", + "allow-atomic-memory-entry", "allow-cancel-memory-reads", "allow-desktop-state", "allow-save-profile", diff --git a/desktop/src-tauri/examples/export_ipc.rs b/desktop/src-tauri/examples/export_ipc.rs index 9bbc3dca37..23e51a5e8c 100644 --- a/desktop/src-tauri/examples/export_ipc.rs +++ b/desktop/src-tauri/examples/export_ipc.rs @@ -26,7 +26,10 @@ use powercontext_desktop::{ diagnostics::{DiagnosticItem, DiagnosticKind, DiagnosticReport, HostDiagnostic}, error::SafeError, ipc::FoundationInfo, - transport::{ApiFailure, wire}, + transport::{ + ApiFailure, AtomicMemoryDetail, MemorySaveResponse, MemorySearchHit, MemorySearchResponse, + wire, + }, }; use ts_rs::TS; fn main() { @@ -65,6 +68,10 @@ fn main() { WriteOutcome::decl(&config), DesktopState::decl(&config), ApiFailure::decl(&config), + MemorySaveResponse::decl(&config), + MemorySearchHit::decl(&config), + MemorySearchResponse::decl(&config), + AtomicMemoryDetail::decl(&config), ] .into_iter() .chain(wire::declarations(&config)) diff --git a/desktop/src-tauri/examples/server_probe.rs b/desktop/src-tauri/examples/server_probe.rs index 3f16465f60..7f5ccfe64f 100644 --- a/desktop/src-tauri/examples/server_probe.rs +++ b/desktop/src-tauri/examples/server_probe.rs @@ -22,7 +22,7 @@ use powercontext_desktop::{ }, credentials::{Secret, WindowsVault}, error::SafeError, - transport::{Endpoint, ServerApi}, + transport::{Endpoint, ServerApi, wire::ArtifactReference}, }; use serde::Deserialize; #[derive(Deserialize)] @@ -91,7 +91,12 @@ async fn main() { ); } let capabilities = api.capabilities().await.unwrap(); - assert!(capabilities.artifact_families.iter().any(|v| v == "memory")); + assert!( + capabilities + .artifact_families + .iter() + .any(|v| v == "atomic-memory") + ); api.default_scope().await.unwrap(); let scopes = api.scopes("", None).await.unwrap(); assert!(!scopes.items.is_empty()); @@ -102,17 +107,19 @@ async fn main() { let keyword = format!("desktop{}", uuid::Uuid::new_v4().simple()); let text = format!("{keyword} 中文记录 café\nSecond line."); let saved = api.remember(&fixture.scope_id, &text).await.unwrap(); - let entry = saved - .entry - .expect("unique synthetic note must produce an exact entry"); + assert_eq!(saved.records.len(), 1); + let entry = &saved.records[0]; let results = api.search(&fixture.scope_id, &keyword).await.unwrap(); assert!( results .hits .iter() - .any(|hit| hit.citation == entry.citation) + .any(|hit| hit.artifact == entry.artifact) ); - let exact = api.entry(&fixture.scope_id, &entry.citation).await.unwrap(); + let exact = api + .atomic_entry(&fixture.scope_id, &entry.artifact) + .await + .unwrap(); assert_eq!(exact.text, text); // Exercise the same native context owner used by product IPC, not only bare HTTP adapters. let temporary = tempfile::tempdir().unwrap(); @@ -145,17 +152,19 @@ async fn main() { let expected = format!("{keyword} 中文 café\nSecond line."); let saved = manager.remember(generation, &submitted).await.unwrap(); assert_eq!(saved.record.status, WriteStatus::Succeeded); - let entry = saved.result.unwrap().entry.unwrap(); + let result = saved.result.unwrap(); + assert_eq!(result.records.len(), 1); + let entry = &result.records[0]; assert_eq!(entry.text, expected); let matches = manager.search_memory(generation, &keyword).await.unwrap(); assert!( matches .hits .iter() - .any(|hit| hit.citation == entry.citation) + .any(|hit| hit.artifact == entry.artifact) ); let exact = manager - .memory_entry(generation, &entry.citation) + .atomic_memory_entry(generation, &entry.artifact) .await .unwrap(); assert_eq!(exact.text, expected); @@ -188,7 +197,7 @@ async fn main() { assert_eq!(matches.hits.len(), 10); let start = std::time::Instant::now(); let exact = manager - .memory_entry(generation, &entry.citation) + .atomic_memory_entry(generation, &entry.artifact) .await .unwrap(); exact_ms.push(start.elapsed().as_secs_f64() * 1000.0); @@ -204,14 +213,14 @@ async fn main() { let results = manager.search_memory(generation, &keyword).await.unwrap(); assert_eq!(results.hits.len(), 1); let committed = manager - .memory_entry(generation, &results.hits[0].citation) + .atomic_memory_entry(generation, &results.hits[0].artifact) .await .unwrap(); assert_eq!(committed.text, text); assert_eq!(std::fs::read_to_string(path).unwrap(), "1"); } if let Some(reader_token) = raw["reader_token"].as_str() { - verify_revocation(&fixture, &raw, reader_token, &entry.citation, &expected).await; + verify_revocation(&fixture, &raw, reader_token, &entry.artifact, &expected).await; verify_scope_pages(&fixture, &raw, &manager, generation).await; } if let Some(path) = fixture.identity_change_path { @@ -219,7 +228,7 @@ async fn main() { std::fs::write(path, b"change").unwrap(); assert_eq!( manager - .memory_entry(generation, &entry.citation) + .atomic_memory_entry(generation, &entry.artifact) .await .err() .unwrap() @@ -230,7 +239,7 @@ async fn main() { assert!(state.active.is_none()); assert!(state.generation > generation); assert_eq!( - api.entry(&fixture.scope_id, &entry.citation) + api.atomic_entry(&fixture.scope_id, &entry.artifact) .await .err() .unwrap() @@ -256,6 +265,8 @@ async fn main() { "{}", serde_json::json!({ "result":"passed", + "qualification":"candidate native real-Server evidence; product profile is separate", + "contractSha256":fixture.compatibility_profile.contract_sha256, "performance": { "scope":"Native ConnectionManager round trips, including identity recheck; local fixture; no UI latency or approved budget", "noteCount":13, @@ -275,7 +286,7 @@ async fn verify_revocation( fixture: &Fixture, raw: &serde_json::Value, reader_token: &str, - citation: &powercontext_desktop::transport::wire::MemoryCitation, + artifact: &ArtifactReference, expected: &str, ) { // Only the isolated test administrator mutates fixture policy; never exposed to Desktop IPC. @@ -306,7 +317,7 @@ async fn verify_revocation( let principal = reader.principal().await.unwrap(); assert_eq!( reader - .entry(&fixture.scope_id, citation) + .atomic_entry(&fixture.scope_id, artifact) .await .unwrap() .text, @@ -327,7 +338,7 @@ async fn verify_revocation( assert_eq!(reader.principal().await.unwrap(), principal); assert_eq!( reader - .entry(&fixture.scope_id, citation) + .atomic_entry(&fixture.scope_id, artifact) .await .err() .unwrap() diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index d0b87222a2..c7dde0c854 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -180,20 +180,20 @@ pub async fn search_memory( state: tauri::State<'_, HostState>, generation: u32, query: String, -) -> Result { +) -> Result { manager(&window, &state)? .search_memory(generation, &query) .await } #[tauri::command] -pub async fn memory_entry( +pub async fn atomic_memory_entry( window: tauri::WebviewWindow, state: tauri::State<'_, HostState>, generation: u32, - citation: crate::transport::wire::MemoryCitation, -) -> Result { + artifact: crate::transport::wire::ArtifactReference, +) -> Result { manager(&window, &state)? - .memory_entry(generation, &citation) + .atomic_memory_entry(generation, &artifact) .await } #[tauri::command] diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index e05d324af4..8d9be29d6e 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1,9 +1,9 @@ [ { - "id": "sqlite-6e237568-v1", - "serverCommit": "6e2375688bc38a667840c6b2dc8269d8848027bc", - "contractSha256": "9af88b2b779c372a21ae9f398d0ca75b333f2e5da2afa3626df879911a442edd", - "artifactSha256": "92c5d14c64969e75e48d30d755c0b1e25d1f8ed9d22e781a8241de0a9098be25", + "id": "sqlite-atomic-f702c041-v1", + "serverCommit": "f702c0418d69e3072e1ec2fa657e0df81e8e51b4", + "contractSha256": "5d8bbad951234b05e3174f1b9113e0ffe066ac968abbe53ac2456b2375e406d3", + "artifactSha256": "4fe64b868a2e214316a088574d8d4023d9b9679ec42029e2d13502121e6aade9", "operations": [ "get_liveness", "get_readiness", @@ -14,27 +14,8 @@ "get_default_scope", "remember_memory", "search_memory", - "get_memory_entry" + "get_artifact_revision" ], - "evidence": "desktop/VALIDATION.md" - }, - { - "id": "sqlite-legacy-58f7f4f6-v1", - "serverCommit": "58f7f4f6cbadd698bac35b3fca019d9f48f55b81", - "contractSha256": "9f4ba7b35ecd3b3639af4e648aff84b713a7f30b51d5a4c30c8809aeb8dc8024", - "artifactSha256": "7203cf1a98cb9dfd8631e142f7a0f5e74c39845da4fb7d862117a49fa1ef38af", - "operations": [ - "get_liveness", - "get_readiness", - "get_access_principal", - "get_capabilities", - "list_scopes", - "get_scope", - "get_default_scope", - "remember_memory", - "search_memory", - "get_memory_entry" - ], - "evidence": "desktop/VALIDATION.md" + "evidence": "desktop/VALIDATION.md#atomic-memory-protocol-qualification" } ] diff --git a/desktop/src-tauri/src/connections/session.rs b/desktop/src-tauri/src/connections/session.rs index 8f83f1c208..5b15e69765 100644 --- a/desktop/src-tauri/src/connections/session.rs +++ b/desktop/src-tauri/src/connections/session.rs @@ -114,14 +114,14 @@ pub struct WriteRecord { pub operation_id: String, pub context: MemoryContext, pub status: WriteStatus, - pub citation: Option, + pub artifact: Option, pub error: Option, } #[derive(Clone, Serialize, TS)] #[serde(rename_all = "camelCase")] pub struct WriteOutcome { pub record: WriteRecord, - pub result: Option, + pub result: Option, } struct WriteGuard<'a> { manager: &'a ConnectionManager, @@ -573,7 +573,7 @@ impl ConnectionManager { &self, generation: u32, query: &str, - ) -> Result { + ) -> Result { let ( ReadSnapshot { api, @@ -592,11 +592,11 @@ impl ConnectionManager { ) .await } - pub async fn memory_entry( + pub async fn atomic_memory_entry( &self, generation: u32, - citation: &MemoryCitation, - ) -> Result { + artifact: &ArtifactReference, + ) -> Result { let ( ReadSnapshot { api, @@ -604,13 +604,13 @@ impl ConnectionManager { mut cancelled, }, context, - ) = self.memory_snapshot(generation, "get_memory_entry")?; + ) = self.memory_snapshot(generation, "get_artifact_revision")?; let mut query_cancelled = self.begin_memory_read(generation)?; Self::cancellable( &mut cancelled, Self::cancellable(&mut query_cancelled, async { self.identity_unchanged(&api, &identity, generation).await?; - api.entry(&context.scope_id, citation).await + api.atomic_entry(&context.scope_id, artifact).await }), ) .await @@ -635,7 +635,7 @@ impl ConnectionManager { operation_id: uuid::Uuid::new_v4().to_string(), context, status: WriteStatus::Pending, - citation: None, + artifact: None, error: None, }; { @@ -654,7 +654,8 @@ impl ConnectionManager { let result = match response { Ok(value) => { record.status = WriteStatus::Succeeded; - record.citation = value.entry.as_ref().map(|e| e.citation.clone()); + record.artifact = + (value.records.len() == 1).then(|| value.records[0].artifact.clone()); Some(value) } Err(error) => { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c9215c282d..508ffe666b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -41,7 +41,7 @@ pub fn run() { commands::local_diagnostics, commands::remember_memory, commands::search_memory, - commands::memory_entry, + commands::atomic_memory_entry, commands::cancel_memory_reads, commands::desktop_state, commands::save_profile, diff --git a/desktop/src-tauri/src/transport/api.rs b/desktop/src-tauri/src/transport/api.rs index 1ff038d40c..baad787d9e 100644 --- a/desktop/src-tauri/src/transport/api.rs +++ b/desktop/src-tauri/src/transport/api.rs @@ -47,6 +47,30 @@ impl From for ApiFailure { } } +#[derive(Clone, Serialize, TS)] +pub struct MemorySaveResponse { + pub changed: bool, + pub records: Vec, +} +#[derive(Clone, Serialize, TS)] +pub struct MemorySearchHit { + pub artifact: ArtifactReference, + pub text: String, + pub score: f64, + pub matched_by: Vec, +} +#[derive(Clone, Serialize, TS)] +pub struct MemorySearchResponse { + pub hits: Vec, +} +#[derive(Clone, Debug, Serialize, TS)] +pub struct AtomicMemoryDetail { + pub artifact: ArtifactReference, + pub kind: String, + pub text: String, + pub source_refs: Vec, +} + pub struct ServerApi { transport: Transport, endpoint: Endpoint, @@ -71,6 +95,18 @@ impl ServerApi { query: &[(String, String)], body: Option, allow_not_ready: bool, + ) -> Result { + self.execute_path(operation, scope, query, body, allow_not_ready, None) + .await + } + async fn execute_path( + &self, + operation: &str, + scope: Option<&str>, + query: &[(String, String)], + body: Option, + allow_not_ready: bool, + artifact: Option<&ArtifactReference>, ) -> Result { let _permit = self .transport @@ -86,7 +122,33 @@ impl ServerApi { let method = Method::from_bytes(method.as_bytes()).map_err(|_| SafeError::InvalidResponse)?; let mut url = self.endpoint.operation_url(operation)?; - if let Some(id) = scope { + if let Some(artifact) = artifact { + if operation != "get_artifact_revision" + || !descriptor["path"].as_str().is_some_and(|p| { + p.ends_with( + "/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", + ) + }) + { + return Err(SafeError::InvalidResponse.into()); + } + let revision = artifact.revision.to_string(); + let mut segments = url + .path_segments_mut() + .map_err(|_| SafeError::InvalidEndpoint)?; + for _ in 0..7 { + segments.pop(); + } + segments.extend([ + "scopes", + scope.ok_or(SafeError::ScopeRequired)?, + "artifacts", + "atomic-memory", + &artifact.artifact_id, + "revisions", + &revision, + ]); + } else if let Some(id) = scope { validate_scope(id)?; // Replace the generated placeholder using URL path-segment encoding, never string interpolation. if !descriptor["path"] @@ -249,7 +311,7 @@ impl ServerApi { &self, scope: &str, text: &str, - ) -> Result { + ) -> Result { validate_scope(scope)?; validate_text(text)?; let result: MemoryMutationResponse = self @@ -261,22 +323,24 @@ impl ServerApi { false, ) .await?; - if !valid_reference(&result.memory) - || result.memory.family != "memory" - || result.entry.as_ref().is_some_and(|entry| { - !valid_entry(entry) || entry.citation.memory_ref != result.memory - }) + if result + .records + .iter() + .any(|record| !valid_atomic_record(record)) { return Err(invalid_received()); } - Ok(result) + Ok(MemorySaveResponse { + changed: result.changed, + records: result.records, + }) } pub async fn search( &self, scope: &str, query: &str, - ) -> Result { + ) -> Result { validate_scope(scope)?; validate_text(query)?; let result: SearchMemoryResponse = self @@ -288,40 +352,79 @@ impl ServerApi { false, ) .await?; - if result.hits.len() > 10 - || result - .hits - .iter() - .any(|hit| validate_citation(&hit.citation).is_err()) - || result - .mode - .as_ref() - .is_some_and(|mode| *mode != MemoryUsedSearchMode::Fts) + if result.mode != AtomicMemorySearchMode::Text + || result.hits.len() > 10 + || result.hits.iter().any(|hit| { + !valid_atomic_record(&hit.memory) + || hit.memory.state != AtomicMemoryState::Active + || !hit.score.is_finite() + || hit + .matched_by + .iter() + .any(|channel| !matches!(channel.as_str(), "text" | "vector")) + }) { return Err(SafeError::InvalidResponse.into()); } - Ok(result) + let hits = result + .hits + .into_iter() + .map(|hit| MemorySearchHit { + artifact: hit.memory.artifact, + text: hit.memory.text, + score: hit.score, + matched_by: hit.matched_by, + }) + .collect(); + Ok(MemorySearchResponse { hits }) } - pub async fn entry( + pub async fn atomic_entry( &self, scope: &str, - citation: &MemoryCitation, - ) -> Result { + artifact: &ArtifactReference, + ) -> Result { validate_scope(scope)?; - validate_citation(citation)?; - let result: MemoryEntry = self - .execute( - "get_memory_entry", - None, + if artifact.family != "atomic-memory" || !valid_reference(artifact) { + return Err(SafeError::InvalidInput.into()); + } + let result: ArtifactRevision = self + .execute_path( + "get_artifact_revision", + Some(scope), &[], - Some(serde_json::json!({"scope_id":scope,"citation":citation})), + None, false, + Some(artifact), ) .await?; - if result.citation != *citation || !valid_entry(&result) { + if result.scope_id != scope + || result.family != ArtifactReadFamily::AtomicMemory + || result.artifact_id != artifact.artifact_id + || result.revision != artifact.revision + || result.content.get("schema").and_then(|v| v.as_str()) + != Some("powercontext.atomic-memory.v1") + || !result.artifacts.iter().all(valid_reference) + { return Err(SafeError::InvalidResponse.into()); } - Ok(result) + let kind = result + .content + .get("kind") + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty() && v.chars().count() <= 128) + .ok_or(SafeError::InvalidResponse)?; + let text = result + .content + .get("text") + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + .ok_or(SafeError::InvalidResponse)?; + Ok(AtomicMemoryDetail { + artifact: artifact.clone(), + kind: kind.into(), + text: text.into(), + source_refs: result.sources, + }) } } pub fn validate_scope(id: &str) -> Result<(), SafeError> { @@ -337,18 +440,6 @@ pub fn validate_text(text: &str) -> Result<(), SafeError> { } Ok(()) } -fn validate_citation(c: &MemoryCitation) -> Result<(), SafeError> { - if c.memory_ref.family != "memory" - || !valid_reference(&c.memory_ref) - || [&c.memory_ref.artifact_id, &c.entry_id, &c.entry_version_id] - .iter() - .any(|v| v.is_empty() || v.len() > 128 || !v.bytes().all(|b| b.is_ascii_graphic())) - { - return Err(SafeError::InvalidInput); - } - Ok(()) -} - fn valid_reference(reference: &ArtifactReference) -> bool { (1..=9_007_199_254_740_991).contains(&reference.revision) && !reference.family.is_empty() @@ -357,10 +448,13 @@ fn valid_reference(reference: &ArtifactReference) -> bool { && reference.artifact_id.len() <= 128 && reference.artifact_id.bytes().all(|b| b.is_ascii_graphic()) } -fn valid_entry(entry: &MemoryEntry) -> bool { - validate_citation(&entry.citation).is_ok() - && (1..=9_007_199_254_740_991).contains(&entry.version) - && entry.artifact_refs.iter().all(valid_reference) +fn valid_atomic_record(record: &AtomicMemoryRecord) -> bool { + record.artifact.family == "atomic-memory" + && valid_reference(&record.artifact) + && (0..=9_007_199_254_740_991).contains(&record.state_version) + && !record.kind.trim().is_empty() + && record.kind.chars().count() <= 128 + && !record.text.is_empty() } fn invalid_received() -> ApiFailure { ApiFailure { diff --git a/desktop/src-tauri/src/transport/mod.rs b/desktop/src-tauri/src/transport/mod.rs index c8c16399fa..aeee5896d7 100644 --- a/desktop/src-tauri/src/transport/mod.rs +++ b/desktop/src-tauri/src/transport/mod.rs @@ -16,7 +16,10 @@ mod api; pub mod wire; -pub use api::{ApiFailure, ServerApi, validate_text}; +pub use api::{ + ApiFailure, AtomicMemoryDetail, MemorySaveResponse, MemorySearchHit, MemorySearchResponse, + ServerApi, validate_text, +}; // Only native adapters use this transport; it is not an IPC fetch primitive. use crate::{credentials::Secret, error::SafeError}; diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 262ef4302a..b506763e37 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "b5631d32c669bbf4b6d340e063ffbbb93a974160380d634ed893dd24b42bb549", + "contractSha256": "6ac34926b9c7f4fa975512833ca026ec43da9073e92b53068e2a987b730f2bb8", "operations": { "get_liveness": { "method": "GET", @@ -33,9 +33,9 @@ "method": "POST", "path": "/v1/memory/search" }, - "get_memory_entry": { - "method": "POST", - "path": "/v1/memory/entries/get" + "get_artifact_revision": { + "method": "GET", + "path": "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}" }, "get_access_principal": { "method": "GET", diff --git a/desktop/src-tauri/src/transport/wire.rs b/desktop/src-tauri/src/transport/wire.rs index 4420d752ad..7790c6dd01 100644 --- a/desktop/src-tauri/src/transport/wire.rs +++ b/desktop/src-tauri/src/transport/wire.rs @@ -133,6 +133,26 @@ pub struct ArtifactFamilyAccessCapability { pub r#grantable_roles: Vec, } +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] +pub enum ArtifactReadFamily { + #[serde(rename = "memory")] + Memory, + #[serde(rename = "atomic-memory")] + AtomicMemory, + #[serde(rename = "experience")] + Experience, + #[serde(rename = "skill")] + Skill, + #[serde(rename = "handoff")] + Handoff, + #[serde(rename = "profile")] + Profile, + #[serde(rename = "prompt")] + Prompt, + #[serde(rename = "topic-memory")] + TopicMemory, +} + #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] #[serde(deny_unknown_fields)] pub struct ArtifactReference { @@ -141,6 +161,61 @@ pub struct ArtifactReference { pub r#revision: i64, } +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] +#[serde(deny_unknown_fields)] +pub struct ArtifactRevision { + pub r#scope_id: String, + pub r#family: ArtifactReadFamily, + pub r#artifact_id: String, + pub r#revision: i64, + #[ts(type = "Record")] + pub r#content: serde_json::Value, + pub r#sources: Vec, + pub r#artifacts: Vec, + pub r#content_digest: String, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] +#[serde(deny_unknown_fields)] +pub struct AtomicMemoryRecord { + pub r#artifact: ArtifactReference, + pub r#kind: String, + pub r#text: String, + pub r#state: AtomicMemoryState, + pub r#state_version: i64, + pub r#merged_into_id: Option, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] +#[serde(deny_unknown_fields)] +pub struct AtomicMemorySearchHit { + pub r#memory: AtomicMemoryRecord, + pub r#score: f64, + pub r#matched_by: Vec, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] +pub enum AtomicMemorySearchMode { + #[serde(rename = "text")] + Text, + #[serde(rename = "vector")] + Vector, + #[serde(rename = "hybrid")] + Hybrid, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] +pub enum AtomicMemoryState { + #[serde(rename = "active")] + Active, + #[serde(rename = "forgotten")] + Forgotten, + #[serde(rename = "merged")] + Merged, + #[serde(rename = "retired")] + Retired, +} + #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] #[serde(deny_unknown_fields)] pub struct Capabilities { @@ -212,62 +287,17 @@ pub struct ExtractionStatus { pub r#observation: ExtractionObservation, } -#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] -#[serde(deny_unknown_fields)] -pub struct GetMemoryEntryRequest { - pub r#scope_id: String, - pub r#citation: MemoryCitation, -} - #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] #[serde(deny_unknown_fields)] pub struct HealthResponse { pub r#status: String, } -#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] -#[serde(deny_unknown_fields)] -pub struct MemoryCitation { - pub r#memory_ref: ArtifactReference, - pub r#entry_id: String, - pub r#entry_version_id: String, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] -#[serde(deny_unknown_fields)] -pub struct MemoryEntry { - pub r#citation: MemoryCitation, - pub r#version: i64, - pub r#kind: String, - pub r#text: String, - pub r#state: MemoryEntryState, - pub r#source_refs: Vec, - pub r#artifact_refs: Vec, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] -pub enum MemoryEntryState { - #[serde(rename = "active")] - Active, - #[serde(rename = "inactive")] - Inactive, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] -pub enum MemoryMatchedBy { - #[serde(rename = "fts")] - Fts, - #[serde(rename = "vector")] - Vector, -} - #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] #[serde(deny_unknown_fields)] pub struct MemoryMutationResponse { - pub r#memory: ArtifactReference, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub r#entry: Option, + pub r#changed: bool, + pub r#records: Vec, } #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] @@ -282,16 +312,6 @@ pub enum MemorySearchMode { Hybrid, } -#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] -pub enum MemoryUsedSearchMode { - #[serde(rename = "fts")] - Fts, - #[serde(rename = "vector")] - Vector, - #[serde(rename = "hybrid")] - Hybrid, -} - #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] pub enum PreparedContextSchema { #[serde(rename = "powercontext.prepared-context.v1")] @@ -383,15 +403,6 @@ pub enum ScopeQueryField { BindingExternalId, } -#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] -#[serde(deny_unknown_fields)] -pub struct SearchMemoryHit { - pub r#citation: MemoryCitation, - pub r#text: String, - pub r#score: f64, - pub r#matched_by: Vec, -} - #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] #[serde(deny_unknown_fields)] pub struct SearchMemoryRequest { @@ -411,19 +422,14 @@ pub struct SearchMemoryRequest { #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] #[serde(deny_unknown_fields)] pub struct SearchMemoryResponse { - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub r#memory: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub r#mode: Option, - pub r#hits: Vec, + pub r#mode: AtomicMemorySearchMode, + pub r#hits: Vec, } #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] #[serde(deny_unknown_fields)] -pub struct SourceReference { - pub r#name: String, +pub struct SourceTypeReference { + pub r#source_type: String, pub r#source_id: String, } @@ -455,21 +461,21 @@ pub fn declarations(config: &ts_rs::Config) -> Vec { ::decl(config), ::decl(config), ::decl(config), + ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), ::decl(config), ::decl(config), ::decl(config), ::decl(config), - ::decl(config), ::decl(config), - ::decl(config), - ::decl(config), - ::decl(config), - ::decl(config), ::decl(config), ::decl(config), - ::decl(config), ::decl(config), ::decl(config), ::decl(config), @@ -479,10 +485,9 @@ pub fn declarations(config: &ts_rs::Config) -> Vec { ::decl(config), ::decl(config), ::decl(config), - ::decl(config), ::decl(config), ::decl(config), - ::decl(config), + ::decl(config), ::decl(config), ::decl(config), ] diff --git a/desktop/src-tauri/tests/api.rs b/desktop/src-tauri/tests/api.rs index e5f5997a9a..37746d821c 100644 --- a/desktop/src-tauri/tests/api.rs +++ b/desktop/src-tauri/tests/api.rs @@ -23,7 +23,11 @@ use tokio::{ net::TcpListener, }; -async fn fixture(status: u16, body: &'static str) -> (ServerApi, tokio::task::JoinHandle) { +async fn fixture( + status: u16, + body: impl Into, +) -> (ServerApi, tokio::task::JoinHandle) { + let body = body.into(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let endpoint = Endpoint::parse(&format!( "http://{}/proxy/%E4%B8%AD%E6%96%87", @@ -169,7 +173,7 @@ fn encoded_base_paths_reject_escape_aliases_and_support_unicode() { #[tokio::test] async fn invalid_successful_write_response_keeps_dispatch_uncertainty() { - let (api, request) = fixture(200, r#"{"memory":{"family":"memory","artifact_id":"m","revision":9007199254740992},"entry":null}"#).await; + let (api, request) = fixture(200, r#"{"changed":true,"records":[{"artifact":{"family":"atomic-memory","artifact_id":"a","revision":9007199254740992},"kind":"note","text":"synthetic note","state":"active","state_version":0,"merged_into_id":null}]}"#).await; let error = api .remember("scope-a", "synthetic note") .await @@ -179,3 +183,47 @@ async fn invalid_successful_write_response_keeps_dispatch_uncertainty() { assert!(error.dispatched); request.await.unwrap(); } + +#[tokio::test] +async fn atomic_records_survive_save_search_and_exact_revision_read() { + let artifact = + serde_json::json!({"family":"atomic-memory","artifact_id":"atomic/a","revision":7}); + let kind = "中".repeat(50); + let text = "中文".repeat(4500); + let record = serde_json::json!({"artifact":artifact,"kind":kind,"text":text,"state":"active","state_version":2,"merged_into_id":null}); + let (api, request) = fixture( + 200, + serde_json::json!({"changed":true,"records":[record]}).to_string(), + ) + .await; + let saved = api.remember("scope-a", "synthetic note").await.unwrap(); + assert!(saved.changed); + assert_eq!(saved.records[0].text, text); + request.await.unwrap(); + let (api, request) = fixture(200, serde_json::json!({"mode":"text","hits":[{"memory":record,"score":1,"matched_by":["text"]}]}).to_string()).await; + let searched = api.search("scope-a", "synthetic").await.unwrap(); + assert_eq!(searched.hits[0].artifact, saved.records[0].artifact); + request.await.unwrap(); + let revision = serde_json::json!({"scope_id":"scope-a","family":"atomic-memory","artifact_id":"atomic/a","revision":7,"content":{"schema":"powercontext.atomic-memory.v1","kind":kind,"text":text},"sources":[{"source_type":"note","source_id":"source-a"}],"artifacts":[],"content_digest":format!("sha256:{}", "0".repeat(64))}); + let (api, request) = fixture(200, revision.to_string()).await; + let exact = api + .atomic_entry("scope-a", &saved.records[0].artifact) + .await + .unwrap(); + assert_eq!(exact.text, text); + assert_eq!(exact.kind, kind); + assert_eq!(exact.source_refs[0].source_id, "source-a"); + assert!(request.await.unwrap().starts_with("GET /proxy/%E4%B8%AD%E6%96%87/v1/scopes/scope-a/artifacts/atomic-memory/atomic%2Fa/revisions/7 ")); + let mut wrong = revision; + wrong["revision"] = serde_json::json!(8); + let (api, request) = fixture(200, wrong.to_string()).await; + assert_eq!( + api.atomic_entry("scope-a", &saved.records[0].artifact) + .await + .err() + .unwrap() + .code, + SafeError::InvalidResponse + ); + request.await.unwrap(); +} diff --git a/desktop/src-tauri/tests/ipc.rs b/desktop/src-tauri/tests/ipc.rs index 665d36a0ab..a3c243db24 100644 --- a/desktop/src-tauri/tests/ipc.rs +++ b/desktop/src-tauri/tests/ipc.rs @@ -21,6 +21,11 @@ use tauri::{ webview::InvokeRequest, }; +fn test_context() -> tauri::Context { + // The macOS plist must be embedded once per test executable. + tauri::generate_context!() +} + #[test] fn permissions_reject_untrusted_window_origin_and_arbitrary_commands() { let dir = tempfile::tempdir().unwrap(); @@ -40,7 +45,7 @@ fn permissions_reject_untrusted_window_origin_and_arbitrary_commands() { powercontext_desktop::commands::desktop_state, powercontext_desktop::commands::disconnect ]) - .build(tauri::generate_context!()) + .build(test_context()) .unwrap(); for (label, origin, command, allowed) in [ ("main", "http://tauri.localhost", "foundation_info", true), @@ -109,7 +114,7 @@ fn diagnostic_command_is_narrow_and_requires_the_trusted_window() { .invoke_handler(tauri::generate_handler![ powercontext_desktop::commands::local_diagnostics ]) - .build(tauri::generate_context!()) + .build(test_context()) .unwrap(); for (label, origin, kind, expected_missing) in [ ("main", "http://tauri.localhost", "service", true), @@ -156,24 +161,21 @@ fn memory_commands_reject_untrusted_callers_before_accessing_the_connection() { .invoke_handler(tauri::generate_handler![ commands::remember_memory, commands::search_memory, - commands::memory_entry, + commands::atomic_memory_entry, commands::cancel_memory_reads ]) - .build(tauri::generate_context!()) + .build(test_context()) .unwrap(); let args = serde_json::json!({ "generation": 0, "text": "synthetic permission test", "query": "synthetic", - "citation": { - "memory_ref": {"family":"memory", "artifact_id":"test", "revision":1}, - "entry_id":"test", "entry_version_id":"v1" - } + "artifact": {"family":"atomic-memory", "artifact_id":"test", "revision":1} }); for command in [ "remember_memory", "search_memory", - "memory_entry", + "atomic_memory_entry", "cancel_memory_reads", ] { for (label, origin) in [ diff --git a/desktop/src-tauri/tests/memory.rs b/desktop/src-tauri/tests/memory.rs index ba074e4a36..c9afde5b7b 100644 --- a/desktop/src-tauri/tests/memory.rs +++ b/desktop/src-tauri/tests/memory.rs @@ -127,10 +127,7 @@ async fn setup() -> Fixture { if status != 200 { (status, serde_json::json!({"detail":"must not leak"})) } else { - ( - 200, - serde_json::json!({"memory":{"family":"memory","artifact_id":"mem-a","revision":1},"entry":null}), - ) + (200, serde_json::json!({"changed":false,"records":[]})) } } path if path.starts_with("/v1/scopes/") => ( @@ -210,7 +207,7 @@ async fn dispatched_write_keeps_its_original_target_and_rejects_duplicate_clicks assert_eq!(outcome.record.context.connection_id, fixture.a); assert_eq!(outcome.record.context.scope_id, "scope-a"); assert!(outcome.result.is_none()); - assert!(outcome.record.citation.is_none()); + assert!(outcome.record.artifact.is_none()); let requests = fixture.requests.lock().unwrap(); assert_eq!( requests.as_slice(), @@ -239,7 +236,7 @@ async fn dispatched_server_failure_is_unknown_and_is_never_replayed() { ); } #[tokio::test] -async fn nullable_entry_success_does_not_invent_a_citation() { +async fn success_without_records_does_not_invent_a_reference() { let fixture = setup().await; fixture.release.notify_one(); let result = fixture @@ -248,8 +245,8 @@ async fn nullable_entry_success_does_not_invent_a_citation() { .await .unwrap(); assert_eq!(result.record.status, WriteStatus::Succeeded); - assert!(result.record.citation.is_none()); - assert!(result.result.unwrap().entry.is_none()); + assert!(result.record.artifact.is_none()); + assert!(result.result.unwrap().records.is_empty()); } #[tokio::test] diff --git a/desktop/tests/installed_boundaries.py b/desktop/tests/installed_boundaries.py index 2bcfafdfa5..b5eb371602 100644 --- a/desktop/tests/installed_boundaries.py +++ b/desktop/tests/installed_boundaries.py @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING import httpx +from installed_fixture import exact_memory_text, search_reference from real_server import HarnessFailure if TYPE_CHECKING: @@ -54,9 +55,8 @@ def exercise_note_budget(page: InstalledPage, server: httpx.Client, scope: str) hits = matches.json()["hits"] if len(hits) != 1: raise HarnessFailure("installed_boundary_note_missing_or_duplicated") - entry = server.post("/v1/memory/entries/get", json={"scope_id": scope, "citation": hits[0]["citation"]}) - entry.raise_for_status() - if entry.json()["text"] != note or len(entry.json()["text"].encode("utf-8")) != 8192: + text = exact_memory_text(server, scope, search_reference(hits[0])) + if text != note or len(text.encode("utf-8")) != 8192: raise HarnessFailure("installed_boundary_note_truncated") diff --git a/desktop/tests/installed_fixture.py b/desktop/tests/installed_fixture.py index 5d23ed0ae2..4c8f0be65d 100644 --- a/desktop/tests/installed_fixture.py +++ b/desktop/tests/installed_fixture.py @@ -28,6 +28,8 @@ from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path +from typing import Any +from urllib.parse import quote import httpx from fixture_qualification import fixture_profile @@ -38,6 +40,30 @@ def fixture_compatibility_profile() -> str: return fixture_profile()["id"] +def search_reference(hit: dict[str, Any]) -> dict[str, Any]: + return hit["memory"]["artifact"] + + +def saved_reference(saved: dict[str, Any]) -> dict[str, Any]: + if len(saved["records"]) != 1: + raise HarnessFailure("installed_saved_atomic_record_count") + return saved["records"][0]["artifact"] + + +def exact_memory_text(server: httpx.Client, scope: str, reference: dict[str, Any]) -> str: + if reference["family"] != "atomic-memory": + raise HarnessFailure("installed_unsupported_memory_reference") + response = server.get( + f"/v1/scopes/{quote(scope, safe='')}/artifacts/atomic-memory/" + f"{quote(reference['artifact_id'], safe='')}/revisions/{reference['revision']}" + ) + response.raise_for_status() + value = response.json() + if value["scope_id"] != scope or any(value[key] != reference[key] for key in ("family", "artifact_id", "revision")): + raise HarnessFailure("installed_independent_exact_read_mismatch") + return value["content"]["text"] + + def wait_ready(client: httpx.Client, process: subprocess.Popen[bytes]) -> None: for _ in range(150): if process.poll() is not None: diff --git a/desktop/tests/installed_lifecycle.py b/desktop/tests/installed_lifecycle.py index 476dcffae6..7d58880437 100644 --- a/desktop/tests/installed_lifecycle.py +++ b/desktop/tests/installed_lifecycle.py @@ -20,7 +20,7 @@ import subprocess import httpx -from installed_fixture import isolated_server +from installed_fixture import exact_memory_text, isolated_server, saved_reference from installed_workflow import ELEMENT, InstalledPage from real_server import HarnessFailure @@ -42,24 +42,20 @@ def exercise_forced_exit(client: httpx.Client, prefix: str, app: subprocess.Pope raise HarnessFailure("installed_unknown_retry_confirmation_missing") page.post("/alert/accept", {}) page.wait_text("保存成功。") - citation = page.search_read(note, "desktoplifecyclectest") + reference = page.search_read(note, "desktoplifecyclectest") if app.poll() is not None: raise HarnessFailure("installed_app_exited_before_forced_exit") # Kill only the exact application process launched by this harness. app.kill() app.wait(timeout=15) server.get("/health/ready").raise_for_status() - original = server.post("/v1/memory/entries/get", json={"scope_id": scope, "citation": citation}) - original.raise_for_status() - if original.json()["text"] != note or original.json()["citation"] != citation: + if exact_memory_text(server, scope, reference) != note: raise HarnessFailure("installed_forced_exit_changed_saved_memory") after_text = "Independent write after Desktop forced exit" written = server.post("/v1/memory/remember", json={"scope_id": scope, "kind": "note", "text": after_text}) written.raise_for_status() - after_citation = written.json()["entry"]["citation"] - read_back = server.post("/v1/memory/entries/get", json={"scope_id": scope, "citation": after_citation}) - read_back.raise_for_status() - if read_back.json()["text"] != after_text or read_back.json()["citation"] != after_citation: + after_reference = saved_reference(written.json()) + if exact_memory_text(server, scope, after_reference) != after_text: raise HarnessFailure("installed_forced_exit_independent_write_unreadable") return { "serverWheelSha256": wheel_digest, diff --git a/desktop/tests/installed_workflow.py b/desktop/tests/installed_workflow.py index ab6e27011b..d0e793fdfa 100644 --- a/desktop/tests/installed_workflow.py +++ b/desktop/tests/installed_workflow.py @@ -23,7 +23,13 @@ import httpx from installed_boundaries import exercise_note_budget, exercise_search_limit -from installed_fixture import fixture_compatibility_profile, isolated_server +from installed_fixture import ( + exact_memory_text, + fixture_compatibility_profile, + isolated_server, + saved_reference, + search_reference, +) from real_server import HarnessFailure ELEMENT = "element-6066-11e4-a52e-4f735466cecf" @@ -190,10 +196,8 @@ def exercise_memory(client: httpx.Client, prefix: str) -> dict[str, object]: raise HarnessFailure("installed_enter_submitted_without_button") page.button("保存") page.wait_text("保存成功。") - citation = page.search_read(NOTE) - response = server.post("/v1/memory/entries/get", json={"scope_id": scope_id, "citation": citation}) - response.raise_for_status() - if response.json()["text"] != NOTE or response.json()["citation"] != citation: + reference = page.search_read(NOTE) + if exact_memory_text(server, scope_id, reference) != NOTE: raise HarnessFailure("installed_independent_exact_read_mismatch") page.button("复制正文") page.wait_text("已复制") @@ -201,11 +205,11 @@ def exercise_memory(client: httpx.Client, prefix: str) -> dict[str, object]: raise HarnessFailure("installed_body_clipboard_mismatch") page.clear_note() page.button("复制精确引用") - if json.loads(page.paste()) != citation: - raise HarnessFailure("installed_citation_clipboard_mismatch") + if json.loads(page.paste()) != reference: + raise HarnessFailure("installed_reference_clipboard_mismatch") page.clear_note() exercise_search_limit(page, server, scope_id) - exercise_connection_isolation(page, server, scope_id, citation) + exercise_connection_isolation(page, server, scope_id, reference) exercise_late_save(page) exercise_unknown_write(page) return { @@ -214,7 +218,7 @@ def exercise_memory(client: httpx.Client, prefix: str) -> dict[str, object]: "explicitConnectionAndScope": True, "saveSearchExactRead": True, "independentServerExactRead": True, - "bodyAndCitationClipboardPaste": True, + "bodyAndReferenceClipboardPaste": True, "twoServerConnectionIsolation": True, "disconnectReconnectClearsContent": True, "unsavedDraftCancelAndDiscard": True, @@ -236,31 +240,29 @@ def current_unchanged_entry(server: httpx.Client, scope: str, original: dict[str current_hits = current.json()["hits"] if len(current_hits) != 1: raise HarnessFailure("installed_original_server_search_ambiguous") - current_citation_a = current_hits[0]["citation"] - # New independent notes advance the artifact revision, while this entry's - # version remains unchanged. Preserve the original citation for exact reads. - if any(current_citation_a[key] != original[key] for key in ("entry_id", "entry_version_id")): - raise HarnessFailure("installed_original_entry_version_changed") - return current_citation_a + current_reference_a = search_reference(current_hits[0]) + if current_reference_a != original: + raise HarnessFailure("installed_original_reference_changed") + return current_reference_a def exercise_connection_isolation( page: InstalledPage, server_a: httpx.Client, scope_a: str, - citation_a: dict[str, object], + reference_a: dict[str, object], ) -> None: - current_citation_a = current_unchanged_entry(server_a, scope_a, citation_a) + current_reference_a = current_unchanged_entry(server_a, scope_a, reference_a) text_b = "desktopinstalledci B 独立服务中的另一条记忆" with isolated_server() as (server_b, scope_b, _): seeded = server_b.post("/v1/memory/remember", json={"scope_id": scope_b, "kind": "note", "text": text_b}) seeded.raise_for_status() - citation_b = seeded.json()["entry"]["citation"] + reference_b = saved_reference(seeded.json()) page.connect("Desktop CI B", str(server_b.base_url).rstrip("/")) page.expect_empty_context() page.select_scope(scope_b) - if page.search_read(text_b) != citation_b: - raise HarnessFailure("installed_second_server_citation_mismatch") + if page.search_read(text_b) != reference_b: + raise HarnessFailure("installed_second_server_reference_mismatch") draft = "desktopunsavedci 不应写入的草稿" page.type("记忆内容", draft, "textarea") page.open_connection_menu() @@ -284,24 +286,22 @@ def exercise_connection_isolation( page.activate("Desktop CI synthetic") page.expect_empty_context() page.select_scope(scope_a) - if page.search_read(NOTE) != current_citation_a: - raise HarnessFailure("installed_reconnected_citation_mismatch") + if page.search_read(NOTE) != current_reference_a: + raise HarnessFailure("installed_reconnected_reference_mismatch") page.button("连接") page.profile("Desktop CI B") page.button("移除连接") page.post("/alert/accept", {}) page.wait("""return ![...document.querySelectorAll('.profile-name')].some( name => name.textContent.trim() === 'Desktop CI B');""") - for server, scope, citation, expected in ( - (server_a, scope_a, citation_a, NOTE), - (server_b, scope_b, citation_b, text_b), + for server, scope, reference, expected in ( + (server_a, scope_a, reference_a, NOTE), + (server_b, scope_b, reference_b, text_b), ): - response = server.post("/v1/memory/entries/get", json={"scope_id": scope, "citation": citation}) - response.raise_for_status() - if response.json()["text"] != expected: + if exact_memory_text(server, scope, reference) != expected: raise HarnessFailure("installed_profile_operation_changed_server_data") page.button("记忆") - if page.search_read(NOTE) != current_citation_a: + if page.search_read(NOTE) != current_reference_a: raise HarnessFailure("installed_inactive_profile_removal_changed_active_connection") @@ -318,7 +318,7 @@ def exercise_unknown_write(page: InstalledPage) -> None: page.wait_text("提交结果未知。") if page.observe("return document.querySelector('.memory-workspace textarea')?.value;") != note: raise HarnessFailure("installed_unknown_write_lost_draft") - citation = page.search_read(note, "desktoplostuici") + reference = page.search_read(note, "desktoplostuici") matches = server.post( "/v1/memory/search", json={ @@ -330,7 +330,7 @@ def exercise_unknown_write(page: InstalledPage) -> None: ) matches.raise_for_status() hits = matches.json()["hits"] - if len(hits) != 1 or hits[0]["citation"] != citation or counter.read_text(encoding="utf-8") != "1": + if len(hits) != 1 or search_reference(hits[0]) != reference or counter.read_text(encoding="utf-8") != "1": raise HarnessFailure("installed_unknown_write_replayed_or_missing") page.clear_note() diff --git a/desktop/ui/src/app/MemoryWorkspace.tsx b/desktop/ui/src/app/MemoryWorkspace.tsx index ed3cdeacad..7187239036 100644 --- a/desktop/ui/src/app/MemoryWorkspace.tsx +++ b/desktop/ui/src/app/MemoryWorkspace.tsx @@ -17,9 +17,9 @@ import { useEffect, useRef, useState } from "react"; import type { DesktopState, - MemoryCitation, - MemoryEntry, - SearchMemoryHit, + AtomicMemoryDetail, + ArtifactReference, + MemorySearchHit, WriteOutcome, } from "../generated/ipc"; import { desktopApi } from "../shared/ipc"; @@ -157,7 +157,7 @@ export function NoteForm({ state, language, onState, onDirty }: Props) { {outcome && (

{outcome.record.status === "succeeded" - ? outcome.result?.entry + ? outcome.result?.records.length ? t.success : t.noEntry : outcome.record.status === "unknown" @@ -175,8 +175,8 @@ export function MemoryWorkspace(props: Props) { const readerHeading = useRef(null); const readButton = useRef(null); const [query, setQuery] = useState(""); - const [hits, setHits] = useState(null); - const [entry, setEntry] = useState(null); + const [hits, setHits] = useState(null); + const [entry, setEntry] = useState(null); const [selected, setSelected] = useState(null); const [busy, setBusy] = useState<"search" | "detail" | null>(null); const [error, setError] = useState(null); @@ -213,21 +213,21 @@ export function MemoryWorkspace(props: Props) { void desktopApi.cancelMemory(active.generation).catch(() => {}); }; }, [active?.generation]); - async function read(citation?: MemoryCitation) { + async function read(reference?: ArtifactReference) { if (!active?.scope) return; const generation = active.generation; const ticket = ++sequence.current; setError(null); setEntry(null); setCopied(""); - setBusy(citation ? "detail" : "search"); - if (citation) setSelected(JSON.stringify(citation)); + setBusy(reference ? "detail" : "search"); + if (reference) setSelected(JSON.stringify(reference)); else setHits(null); try { await pendingCancel.current; if (ticket !== sequence.current || generation !== current.current) return; - if (citation) { - const result = await desktopApi.entry(generation, citation); + if (reference) { + const result = await desktopApi.atomicEntry(generation, reference); if (ticket === sequence.current && generation === current.current) setEntry(result); } else { @@ -260,7 +260,7 @@ export function MemoryWorkspace(props: Props) { if (!entry) return; try { await navigator.clipboard.writeText( - reference ? JSON.stringify(entry.citation, null, 2) : entry.text, + reference ? JSON.stringify(entry.artifact, null, 2) : entry.text, ); setCopied(t.copied); } catch { @@ -356,7 +356,7 @@ export function MemoryWorkspace(props: Props) { {hits && hits.length > 0 && (

    {hits.map((hit) => { - const key = JSON.stringify(hit.citation); + const key = JSON.stringify(hit.artifact); const title = hit.text.split("\n", 1)[0] || hit.text; return (
  • @@ -367,10 +367,12 @@ export function MemoryWorkspace(props: Props) {
    {hit.text}
    - {hit.matched_by.includes("fts") && ( + {(hit.matched_by.includes("fts") || + hit.matched_by.includes("text")) && ( {t.hitFts} )} {!hit.matched_by.includes("fts") && + !hit.matched_by.includes("text") && hit.matched_by.includes("vector") && ( {t.hitVector} )} @@ -379,7 +381,7 @@ export function MemoryWorkspace(props: Props) { aria-pressed={selected === key} onClick={(event) => { readButton.current = event.currentTarget; - void read(hit.citation); + void read(hit.artifact); }} > {busy === "detail" && selected === key @@ -401,16 +403,7 @@ export function MemoryWorkspace(props: Props) {

    {t.detail}

    - - {entry.kind}{" "} - - {entry.state === "active" ? t.badgeActive : t.badgeInactive} - - + {entry.kind}
    {entry.text} @@ -420,17 +413,17 @@ export function MemoryWorkspace(props: Props) { {t.scopeBelong} {active.scope.title}

    )} -
    +

    {t.reference}

    -
    -
    Memory revision
    -
    {entry.citation.memory_ref.revision}
    -
    Entry ID
    -
    {entry.citation.entry_id}
    -
    Entry version
    -
    {entry.citation.entry_version_id}
    +
    +
    Artifact family
    +
    {entry.artifact.family}
    +
    Artifact ID
    +
    {entry.artifact.artifact_id}
    +
    Revision
    +
    {entry.artifact.revision}
    -

    {t.citationNote}

    +

    {t.referenceNote}

    -
    -

    {{ selected_note.text if expanded and selected_note else item.text }}

    +
    +
    {% if expanded %}{% include 'components/memory-metadata.html' %}{% endif %}

    {{ item.text }}

    + {% if expanded %}{% from 'components/record-references.html' import record_references with context %}{{ record_references(item, item.family) }}{% endif %} +
    {% else %} diff --git a/src/powercontext/server/dashboard/templates/components/memory-metadata.html b/src/powercontext/server/dashboard/templates/components/memory-metadata.html new file mode 100644 index 0000000000..4b3465cf01 --- /dev/null +++ b/src/powercontext/server/dashboard/templates/components/memory-metadata.html @@ -0,0 +1,22 @@ + + +
    +

    {{ t.note_kind }}: {{ t.get(item.kind, item.kind) }}

    +

    {{ t.current_memory_state }}: {{ t['memory_state_' ~ item.state] }} · {{ t.state_version }} {{ item.state_version }}

    + {% if not item.is_current %}

    {{ t.historical_revision }} · {{ t.current_revision }} @{{ item.current_artifact.revision }}

    {% endif %} + {% if item.merged_into_id %}

    {{ t.merged_into }}: {{ item.merged_into_id }}

    {% endif %} +
    diff --git a/src/powercontext/server/dashboard/templates/components/read-error.html b/src/powercontext/server/dashboard/templates/components/read-error.html index 4d74ce46b8..fa30d212a0 100644 --- a/src/powercontext/server/dashboard/templates/components/read-error.html +++ b/src/powercontext/server/dashboard/templates/components/read-error.html @@ -20,7 +20,7 @@

    {{ label or t.error_heading }}

    {{ t.get('error_' ~ error.status, t.error_503) }}

    {{ t.retry }} - {% if page == 'handoff-download' and reading_return %}{{ t.return_handoff }}{% elif page == 'profile' %}{{ t.profile_history if profile_history_view else t.profile_current }}{% elif page == 'handoff-detail' and return_to %}{{ t.back_list }}{% elif not scope_descriptor %}{{ t.scope_label }}{% else %}{{ t.back_home if page == 'home' else t.back_list }}{% endif %} + {% if page == 'handoff-download' and reading_return %}{{ t.return_handoff }}{% elif page == 'profile' %}{{ t.profile_history if profile_history_view else t.profile_current }}{% elif page == 'handoff-detail' and return_to %}{{ t.back_list }}{% elif not scope_descriptor %}{{ t.scope_label }}{% else %}{{ t.back_home if page == 'home' else t.back_list }}{% endif %}
    {% if error.request_id %}

    {{ t.request_id }}: {{ error.request_id }}

    {% endif %} diff --git a/src/powercontext/server/dashboard/templates/components/record-references.html b/src/powercontext/server/dashboard/templates/components/record-references.html index fff880d1f3..aa0ec3b057 100644 --- a/src/powercontext/server/dashboard/templates/components/record-references.html +++ b/src/powercontext/server/dashboard/templates/components/record-references.html @@ -23,7 +23,7 @@ {% if caller is defined %}{{ caller() }}{% endif %} {% if record.artifact_id %}{{ family }}/{{ record.artifact_id }}{% if record.revision %}@{{ record.revision }}{% endif %}{% endif %} {% for source in record.sources if source.source_id %}{{ source.source_type }}/{{ source.source_id }}{% endfor %} - {% set pages = {'experience': 'experience', 'skill': 'skill', 'handoff': 'handoff-detail'} %} + {% set pages = {'atomic-memory': 'notes', 'experience': 'experience', 'skill': 'skill', 'handoff': 'handoff-detail'} %} {% for artifact in record.artifacts if artifact.artifact_id %} {% if artifact.family in pages and artifact.revision %} {{ artifact.family }}/{{ artifact.artifact_id }}@{{ artifact.revision }} @@ -31,12 +31,6 @@ {{ artifact.family }}/{{ artifact.artifact_id }}{% if artifact.revision %}@{{ artifact.revision }}{% endif %} {% endif %} {% endfor %} - {% if record.memory_citations %} -

    {{ t.memory_citations }}

    - {% for citation in record.memory_citations %} - memory/{{ citation.memory_ref.artifact_id }}@{{ citation.memory_ref.revision }} · {{ citation.entry_id }} · {{ citation.entry_version_id }} - {% endfor %} - {% endif %} {% endif %} {% endmacro %} diff --git a/src/powercontext/server/dashboard/templates/components/search.html b/src/powercontext/server/dashboard/templates/components/search.html index f1b48c799b..ed4c913db4 100644 --- a/src/powercontext/server/dashboard/templates/components/search.html +++ b/src/powercontext/server/dashboard/templates/components/search.html @@ -31,7 +31,7 @@
    - + {% endmacro %} diff --git a/src/powercontext/server/dashboard/templates/macros.html b/src/powercontext/server/dashboard/templates/macros.html index 5366b2de15..5af3bad162 100644 --- a/src/powercontext/server/dashboard/templates/macros.html +++ b/src/powercontext/server/dashboard/templates/macros.html @@ -34,7 +34,7 @@ diff --git a/src/powercontext/server/dashboard/templates/notes.html b/src/powercontext/server/dashboard/templates/notes.html index 572e5ab129..b9296a2bac 100644 --- a/src/powercontext/server/dashboard/templates/notes.html +++ b/src/powercontext/server/dashboard/templates/notes.html @@ -26,6 +26,9 @@ {% from 'components/memory-search-evidence.html' import search_evidence %} {{ page_heading(t.notes, t.notes_subtitle) }} {{ search_form(t.search_notes, 8192, mode=search_mode) }} +{% if not search_query %}{% endif %} {% if search_query and not errors.get('notes') %}

    {{ t.memory_requested_mode }}: {{ t.get('memory_mode_' ~ search_mode, search_mode) }} · {{ t.memory_used_mode }}: {{ t.get('memory_mode_' ~ search_used_mode, search_used_mode) if search_used_mode else t.unknown_value }}

    {% endif %} {% if search_limited %}

    {{ t.notes_search_limit }}

    {% endif %} {% if errors.get('notes') %}{{ read_error(errors.notes, t.notes) }}{% endif %} @@ -39,7 +42,7 @@ @media (min-width: 1200px) { me { min-height: 65dvh; } }