diff --git a/crates/temperpaw/tests/paw_fs_hot_path.rs b/crates/temperpaw/tests/paw_fs_hot_path.rs index 1495e8bc6..190e5cec3 100644 --- a/crates/temperpaw/tests/paw_fs_hot_path.rs +++ b/crates/temperpaw/tests/paw_fs_hot_path.rs @@ -104,6 +104,49 @@ fn monty_exposes_write_many_for_artifact_sets() { ); } +#[test] +fn genesis_publish_git_commands_are_noninteractive_and_authenticated() { + let source = repo_file("os-apps/paw-agent/wasm/monty_repl/src/dispatch.rs"); + assert!( + source.contains("genesis_registry_auth_header"), + "publish_app/update_app must derive an Authorization extraHeader for Genesis git smart HTTP" + ); + assert!( + source.contains("Authorization: Bearer"), + "Genesis git auth should use the GitToken bearer contract accepted by genesis git_auth" + ); + assert!( + source.contains("GIT_TERMINAL_PROMPT=0"), + "Genesis git publish must fail deterministically instead of prompting agents for username/password" + ); +} + +#[test] +fn pawfs_single_entity_lookups_are_bounded_to_one_result() { + let monty_source = repo_file("os-apps/paw-agent/wasm/monty_repl/src/entity_ops.rs"); + for (collection, helper) in [ + ("Directories", "find_pawfs_directory"), + ("Files", "find_pawfs_file"), + ] { + assert!( + monty_source.contains(&format!("/tdata/{collection}?$filter={{filter}}&$top=1")), + "{helper} must bound direct PawFS lookups with $top=1 so hot-path writes cannot trip OData QueryTooLarge on duplicate/stale rows" + ); + } + assert!( + monty_source.contains("pawfs_stable_entity_id(\"dr\", ws_id, &path)") + && monty_source.contains("is_pawfs_lookup_too_large(&error)") + && monty_source.contains("\"Id\": directory_id"), + "Directory creation must fall back to stable idempotent creates when path lookups still trip QueryTooLarge" + ); + + let artifact_batch_source = repo_file("os-apps/paw-fs/wasm/artifact_batch_apply/src/lib.rs"); + assert!( + artifact_batch_source.contains("/tdata/{set_name}?$filter={encoded}&$top=1"), + "artifact_batch_apply single-entity lookup must also be bounded with $top=1" + ); +} + #[test] fn default_agent_tool_allowlists_include_write_many() { for path in [ diff --git a/crates/temperpaw/tests/session_turn_architecture.rs b/crates/temperpaw/tests/session_turn_architecture.rs index b45e1b7d9..8fcbee31f 100644 --- a/crates/temperpaw/tests/session_turn_architecture.rs +++ b/crates/temperpaw/tests/session_turn_architecture.rs @@ -375,6 +375,16 @@ fn session_entry_readbacks_stay_within_bounded_query_budget() { helpers.contains("session_entries_verify_urls"), "batched SessionEntry readback should use one bounded per-entry URL per expected entry" ); + assert!( + helpers.contains("$orderby=Sequence%20asc,EntryId%20asc"), + "SessionEntry context reads should use deterministic bounded pages instead of an unordered session-wide scan" + ); + assert!( + helpers.contains("read_session_from_entries_chain") + && helpers.contains("session_leaf_id") + && helpers.contains("ParentEntryId"), + "SessionEntry context reads with a known leaf should walk bounded per-entry parent links instead of listing the whole session" + ); assert!( route_message.contains("session_leaf_id is missing; starting clean continuation"), "route_message should start cleanly instead of broad-scanning when the prior leaf hint is missing" diff --git a/docs/adrs/0066-agent-genesis-git-publish-auth.md b/docs/adrs/0066-agent-genesis-git-publish-auth.md new file mode 100644 index 000000000..81a3ea558 --- /dev/null +++ b/docs/adrs/0066-agent-genesis-git-publish-auth.md @@ -0,0 +1,57 @@ +# ADR-0066: Agent Genesis Git Publish Auth + +- Status: Proposed +- Date: 2026-06-18 +- Deciders: TemperPaw maintainers + +## Context + +TemperPaw agents repair installed apps by editing a workspace copy, calling +`temper.publish_app(...)` or `temper.update_app(...)`, then installing the +returned pinned Genesis ref. The registry-side OData actions can be reached with +the existing agent headers, but Genesis smart HTTP git receive-pack rejects +anonymous pushes. + +The previous publish path configured only `X-Tenant-Id` as a git extraHeader. +When Genesis required authentication, agents saw interactive git credential +failures such as "could not read Username" instead of a deterministic tool error. +That made app repairs easy to leave as local-only edits. + +Genesis already accepts active GitToken secrets through `Authorization: Bearer` +on git smart HTTP requests. + +## Decision + +`temper.publish_app(...)` and `temper.update_app(...)` will resolve a Genesis +GitToken from a Temper secret and send it to git as an extraHeader: + +- default secret names, tried in order: `GENESIS_GIT_TOKEN`, then the existing + production-compatible `GENESIS_TOKEN` +- per-call overrides: `registry_token_secret`, `git_token_secret`, + `genesis_git_token_secret`, `genesis_token_secret`, or `RegistryTokenSecret` +- agent config overrides: `genesis_registry_token_secret`, + `GENESIS_REGISTRY_TOKEN_SECRET`, `genesis_git_token_secret`, or + `GENESIS_GIT_TOKEN_SECRET`, `genesis_token_secret`, or `GENESIS_TOKEN_SECRET` + +The git command also sets `GIT_TERMINAL_PROMPT=0` so missing or invalid auth +fails immediately with explicit evidence. + +The token is not returned in tool results. Successful publish/update results +continue to return the pinned `owner/name@hash` ref and verified Genesis latest +hash metadata. + +## Consequences + +- Agent app repairs can push to Genesis without human credential prompts. +- Missing publish credentials fail before a local-only change can be mistaken + for an installed repair. +- Deployments must provision an active Genesis GitToken in the configured Temper + secret before agents can publish or update apps. +- The existing install step remains mandatory: pushing a new Genesis version is + not the same thing as installing that pinned ref into a tenant. + +## Non-Goals + +- This ADR does not change Genesis GitToken validation semantics. +- This ADR does not add password-based git credentials. +- This ADR does not make unpinned app installs acceptable for agents. diff --git a/docs/deployment.md b/docs/deployment.md index a8e4bb834..b1f33692c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -30,6 +30,10 @@ repo-local app catalog. Configure fresh-instance bootstrap with pinned refs: - `TEMPERPAW_GENESIS_BOOTSTRAP_REFS=temperpaw/paw-fs@HASH,temperpaw/paw-agent@HASH` - `TEMPERPAW_GENESIS_CACHE_RESTORE_TIMEOUT_SECS=20` - `TEMPERPAW_GENESIS_BOOTSTRAP_TIMEOUT_SECS=60` +- Temper secret `GENESIS_GIT_TOKEN` or `GENESIS_TOKEN`, or + `GENESIS_GIT_TOKEN_SECRET` / `GENESIS_TOKEN_SECRET` pointing at an equivalent + active Genesis GitToken secret, for agent `publish_app` and `update_app` git + pushes. On restart with the same database, TemperPaw restores installed Genesis app metadata and skips unchanged bootstrap refs. Do not reset, wipe, replace, or diff --git a/docs/proofs/2026-06-18-katagami-publish-e2e.md b/docs/proofs/2026-06-18-katagami-publish-e2e.md new file mode 100644 index 000000000..3a56bd196 --- /dev/null +++ b/docs/proofs/2026-06-18-katagami-publish-e2e.md @@ -0,0 +1,204 @@ +# Katagami Publish Path E2E Proof + +- Date: 2026-06-18 +- Linear: ARN-51, ARN-52, ARN-54, ARN-55, ARN-57 +- TemperPaw PR: https://github.com/nerdsane/temperpaw/pull/415 +- Genesis PR: https://github.com/arni-labs/genesis/pull/36 + +## Scope + +This proof covers the Katagami curation artifact publishing path after fixing +the app publish/install path, Genesis receive-pack auth support, PawFS hotload +writes, the Ready File invariant, verification evidence, and installed app +provenance. + +The final production verifier required more than a successful agent response: +the session had to create a `Files` entity in workspace `katagami` with +`Status=Ready`, and the bytes read from `/tdata/Files('{id}')/$value` had to +match the expected SHA-256. + +## Issues Found + +1. Genesis receive-pack could not accept an authenticated app publish when the + pushed pack referenced a base object that existed only in the server object + database. This blocked publishing the updated `temperpaw/paw-agent` app. +2. `context_preparer` still used broad `SessionEntry` collection reads in hot + sessions, which hit `HTTP 413 QueryTooLarge` in production. +3. The first-turn virtual SessionEntry leaf was queried as if it already + existed durably, which caused another bounded-query 413 on the virtual + `u-{session_id}-0` leaf. +4. PawFS directory lookup for `/proofs` used an exact filtered collection read + that also hit `HTTP 413 QueryTooLarge`; the agent reported done, but no Ready + File existed. +5. The proof file and Linear state still described the old blocked state instead + of the live installed app and Ready File evidence. +7. OpenPaw bootstrap refs could drift back to the older `paw-agent` app hash + after restart unless the pinned hash was updated. + +No duplicate Linear issues were created; updates were added to the existing +issues, primarily ARN-54 for the bounded-query PawFS hotload blocker. + +## Fixes + +### Genesis + +- Commit: `4e48827 Fix receive-pack delta base fallback` +- PR: https://github.com/arni-labs/genesis/pull/36 +- Deployment: `fa798fd2-a606-47ea-b1a8-b3ec0ac0b083` +- Image digest: + `sha256:98e07c2ffb49b49000f798e53bf19ab12b98873e8cf7941ae955084620ad487c` +- Result: receive-pack accepts the authenticated publish path used for + `temperpaw/paw-agent`. + +### TemperPaw + +- `66cd238a300cfe8601a29435097024dac620bb39` + `Fix Genesis publish auth and bounded PawFS lookups` +- `b09358cf` `Bound SessionEntry context reads` +- `da31ed64` `Walk SessionEntry chains by leaf` +- `21fa21d4` `Skip OData for virtual first turn leaf` +- `97dff829` `Fallback PawFS directory creates on query budget` + +Key behavior changes: + +- `wasm-helpers` now supports bounded SessionEntry chain reads from a known + `session_leaf_id`. +- The virtual first-turn leaf `u-{session_id}-0` is recognized without issuing + an OData lookup. +- `context_preparer` passes the session leaf into the helper instead of + falling back to a broad session read. +- PawFS directory creation now uses stable path-scoped entity ids and treats + query-budget lookup failures as a signal to perform idempotent create. + +## Local Verification + +- Red/green: + `cargo test -p temperpaw --test session_turn_architecture session_entry_readbacks_stay_within_bounded_query_budget -- --nocapture` +- Red/green: + `cargo test virtual_first_turn_leaf_is_detected_without_odata_probe -- --nocapture` +- Red/green: + `cargo test pawfs_stable_directory_ids_are_path_scoped -- --nocapture` +- Full focused tests: + `cargo test -p temperpaw --test session_turn_architecture -- --nocapture` + - Result: 24 passed. +- Full helper tests: + `cargo test -- --nocapture` + - Path: `os-apps/paw-agent/wasm/wasm-helpers` + - Result: 39 passed. +- PawFS hot path tests: + `cargo test -p temperpaw --test paw_fs_hot_path -- --nocapture` + - Result: 15 passed. +- Monty REPL PawFS tests: + `cargo test pawfs_ -- --nocapture` + - Path: `os-apps/paw-agent/wasm/monty_repl` + - Result: 5 passed. +- Formatting: + `cargo fmt --check` + - Result: passed. +- WASM build: + `./build.sh` + - Path: `os-apps/paw-agent/wasm` + - Result: passed with pre-existing warnings in unrelated modules. + +## Publish And Install Evidence + +Final published `paw-agent` app hash: + +- `temperpaw/paw-agent@18f58e340795e66015e428534679222eb2afaced` + +Genesis readback: + +- `LatestVersionHash=18f58e340795e66015e428534679222eb2afaced` +- `NewHash=18f58e340795e66015e428534679222eb2afaced` +- `RefName=main` +- `Status=Active` + +Fresh Genesis clone evidence: + +- `clone_head=18f58e340795e66015e428534679222eb2afaced` +- `git fsck --full`: ok +- tracked WASM count: 21 +- PawFS stable directory guard present in cloned `wasm/monty_repl` source. + +OpenPaw install evidence: + +- Install API: `HTTP 200` +- Installed app ref: + `temperpaw/paw-agent@18f58e340795e66015e428534679222eb2afaced` + +Production installed app provenance: + +- `paw-agent` + - `source_kind=genesis` + - `version_hash=18f58e340795e66015e428534679222eb2afaced` + - `pinned_version_hash=18f58e340795e66015e428534679222eb2afaced` + - `current_version_hash=18f58e340795e66015e428534679222eb2afaced` + - `follow_policy=pinned` + - `status=installed` +- `paw-fs` + - `8cc9c1a0c3959ba0555a6eac5446db76de747817` +- `katagami-curation` + - `1e6f43993be70ca3d7dadf42c032fa6a206ac482` + +Bootstrap persistence: + +- `TEMPERPAW_GENESIS_BOOTSTRAP_REFS` now points `temperpaw/paw-agent` at + `18f58e340795e66015e428534679222eb2afaced`. + +OpenPaw deployment: + +- Deployment: `2545e028-efe2-4700-9e07-7072a73a0076` +- Status: `SUCCESS` +- `/healthz`: HTTP 200 +- `/readyz`: HTTP 200 + +## Failed Live Attempts During Diagnosis + +- `ss-hotload-e2e-20260618043245` + - Failed in `context_preparer`. + - Error: `SessionEntry list failed (HTTP 413): QueryTooLarge`. +- `ss-hotload-e2e-20260618045510` + - Failed while reading the virtual first-turn leaf. + - Error: `SessionEntry chain read failed for EntryId=u-...-0 (HTTP 413): QueryTooLarge`. +- `ss-hotload-e2e-20260618050218` + - Session completed with `(done)`, but independent file verification failed. + - SessionEntry forensic evidence showed `temper.write` failed on directory + lookup: + `GET /tdata/Directories?$filter=Path eq '/proofs' and WorkspaceId eq 'katagami' ...` + returned `413 QueryTooLarge`. + - Exact Ready File query returned zero rows. + +## Final Live E2E + +Production session: + +- Session: `ss-hotload-e2e-20260618051545` +- Proof path: `/proofs/katagami-hotload-e2e-20260618051545.md` +- Workspace: `katagami` +- Runtime app hash: + `temperpaw/paw-agent@18f58e340795e66015e428534679222eb2afaced` +- Session final status: `Completed` +- Session result: `(done)` + +Independent Ready File verification: + +- Ready file count: 1 +- File id: `fl-019ed928-b94c-7953-adaa-37981496cad3` +- Expected SHA-256: + `ce32628b7cd1c561ca7a23401a1e3998c1c0820f4b23150bfbafb41ec1033e70` +- Actual SHA-256: + `ce32628b7cd1c561ca7a23401a1e3998c1c0820f4b23150bfbafb41ec1033e70` +- Result: `ready_file_content_match=true` + +## ADR Note + +No new ADR was added for the final two TemperPaw code changes because they are +bounded implementation corrections to already-accepted SessionEntry and PawFS +hot-path architecture decisions: + +- bounded SessionEntry reads are covered by the existing SessionEntry hot-path + ADR set; +- stable PawFS directory creation preserves the existing entity model and only + makes the create path idempotent when the exact lookup exceeds query budget. + +The judgement is recorded here and should also be linked from the PR notes. diff --git a/os-apps/paw-agent/system/skills/platform-awareness/SKILL.md b/os-apps/paw-agent/system/skills/platform-awareness/SKILL.md index 01f636b11..8cdf59743 100644 --- a/os-apps/paw-agent/system/skills/platform-awareness/SKILL.md +++ b/os-apps/paw-agent/system/skills/platform-awareness/SKILL.md @@ -229,6 +229,11 @@ new_ref = temper.update_app({ temper.install_app({"app_ref": new_ref, "follow_policy": "pinned", "reason": "Roll forward repaired app"}) ``` +`publish_app` and `update_app` require a configured Genesis GitToken secret +(`GENESIS_GIT_TOKEN` or `GENESIS_TOKEN` by default, or an explicit +`registry_token_secret`). Missing auth is a publish blocker; it is not a +successful repair until the returned pinned ref is installed and verified. + Then verify the entity/action that was broken. Report the old pinned ref, the new pinned ref, and the smoke result. A normal app repair is a new version of the same Genesis app; it is not a fork or lineage change unless you are creating diff --git a/os-apps/paw-agent/system/skills/temper-app-creation/SKILL.md b/os-apps/paw-agent/system/skills/temper-app-creation/SKILL.md index ac08a04f5..ac99de0a1 100644 --- a/os-apps/paw-agent/system/skills/temper-app-creation/SKILL.md +++ b/os-apps/paw-agent/system/skills/temper-app-creation/SKILL.md @@ -326,6 +326,11 @@ new_ref = temper.update_app({ temper.install_app({"app_ref": new_ref, "follow_policy": "pinned", "reason": "Install repaired app"}) ``` +`publish_app` and `update_app` use the configured Genesis GitToken secret +(`GENESIS_GIT_TOKEN` or `GENESIS_TOKEN` by default, or `registry_token_secret` +when explicitly provided). If auth is missing, stop and report the failed +publish instead of claiming the local edit is installed. + Verify the broken entity/action after install. A repair is a new version of the same Genesis app. Use lineage only when creating a fork/import/derivative app. diff --git a/os-apps/paw-agent/wasm/context_preparer/src/lib.rs b/os-apps/paw-agent/wasm/context_preparer/src/lib.rs index e91b3ddad..1b569a74c 100644 --- a/os-apps/paw-agent/wasm/context_preparer/src/lib.rs +++ b/os-apps/paw-agent/wasm/context_preparer/src/lib.rs @@ -499,15 +499,17 @@ fn read_session_from_temperfs( temper_api_url: &str, tenant: &str, file_id: &str, + session_leaf_id: Option<&str>, ) -> Result { if wasm_helpers::is_session_entries_ref(file_id) { let fields = ctx.entity_state.get("fields").cloned().unwrap_or(json!({})); - return wasm_helpers::read_session_from_temperfs( + return wasm_helpers::read_session_from_temperfs_with_leaf( ctx, temper_api_url, tenant, &fields, file_id, + session_leaf_id, ); } @@ -1213,7 +1215,13 @@ fn load_messages_for_prepare( let use_session_tree = !session_file_id.is_empty() && !session_leaf_id.is_empty(); if use_session_tree { let session_jsonl = - read_session_from_temperfs(ctx, temper_api_url, tenant, session_file_id)?; + read_session_from_temperfs( + ctx, + temper_api_url, + tenant, + session_file_id, + Some(session_leaf_id), + )?; if session_jsonl.is_empty() { if is_session_entries_ref(session_file_id) { ctx.log( diff --git a/os-apps/paw-agent/wasm/monty_repl/src/dispatch.rs b/os-apps/paw-agent/wasm/monty_repl/src/dispatch.rs index c6e9beea8..2d10513d3 100644 --- a/os-apps/paw-agent/wasm/monty_repl/src/dispatch.rs +++ b/os-apps/paw-agent/wasm/monty_repl/src/dispatch.rs @@ -2369,6 +2369,7 @@ fn temper_publish_app( let name = required_obj_str(input, "name", "publish_app")?; let registry_url = genesis_registry_url(ctx, Some(input))?; let registry_tenant = genesis_registry_tenant(input); + let registry_auth_header = genesis_registry_auth_header(ctx, input)?; let message = input .get("message") .and_then(Value::as_str) @@ -2384,6 +2385,7 @@ fn temper_publish_app( &name, ®istry_url, ®istry_tenant, + ®istry_auth_header, message, ) } @@ -2404,6 +2406,7 @@ fn temper_update_app( let (owner, name) = owner_name_from_ref_or_name(&app_ref_or_name)?; let registry_url = genesis_registry_url(ctx, Some(input))?; let registry_tenant = genesis_registry_tenant(input); + let registry_auth_header = genesis_registry_auth_header(ctx, input)?; let message = input .get("message") .and_then(Value::as_str) @@ -2419,6 +2422,7 @@ fn temper_update_app( &name, ®istry_url, ®istry_tenant, + ®istry_auth_header, message, ) } @@ -2434,6 +2438,7 @@ fn publish_or_update_app_via_git( name: &str, registry_url: &str, registry_tenant: &str, + registry_auth_header: &str, message: &str, ) -> Result { if sandbox_url.is_empty() { @@ -2468,11 +2473,12 @@ fn publish_or_update_app_via_git( let git_tenant_header = format!("X-Tenant-Id: {registry_tenant}"); let command = format!( "set -euo pipefail\n\ + export GIT_TERMINAL_PROMPT=0\n\ source_dir={}\n\ publish_dir=$(mktemp -d)\n\ cleanup() {{ rm -rf \"$publish_dir\"; }}\n\ trap cleanup EXIT\n\ - if ! git -c {}={} -c protocol.version=0 clone {} \"$publish_dir\" >/dev/null 2>&1; then\n\ + if ! git -c {}={} -c {}={} -c protocol.version=0 clone {} \"$publish_dir\" >/dev/null 2>&1; then\n\ git init -b main \"$publish_dir\" >/dev/null 2>&1 || (git init \"$publish_dir\" >/dev/null && git -C \"$publish_dir\" checkout -B main >/dev/null)\n\ git -C \"$publish_dir\" remote add origin {}\n\ fi\n\ @@ -2483,6 +2489,7 @@ fn publish_or_update_app_via_git( cd \"$publish_dir\"\n\ git config --unset-all {} >/dev/null 2>&1 || true\n\ git config --add {} {}\n\ + git config --add {} {}\n\ git config protocol.version 0\n\ git add .\n\ if ! git diff --cached --quiet; then git -c user.name={} -c user.email={} commit -m {} >/dev/null; fi\n\ @@ -2498,11 +2505,15 @@ fn publish_or_update_app_via_git( shell_quote(path), shell_quote(&git_header_key), shell_quote(&git_tenant_header), + shell_quote(&git_header_key), + shell_quote(registry_auth_header), shell_quote(&remote), shell_quote(&remote), shell_quote(&git_header_key), shell_quote(&git_header_key), shell_quote(&git_tenant_header), + shell_quote(&git_header_key), + shell_quote(registry_auth_header), shell_quote("TemperPaw Agent"), shell_quote("agent@temperpaw.local"), shell_quote(message), @@ -2780,6 +2791,47 @@ fn genesis_registry_tenant(input: &serde_json::Map) -> String { .to_string() } +fn genesis_registry_auth_header( + ctx: &Context, + input: &serde_json::Map, +) -> Result { + let configured_secret_name = input + .get("registry_token_secret") + .or_else(|| input.get("git_token_secret")) + .or_else(|| input.get("genesis_git_token_secret")) + .or_else(|| input.get("genesis_token_secret")) + .or_else(|| input.get("RegistryTokenSecret")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .or_else(|| ctx.config.get("genesis_registry_token_secret").cloned()) + .or_else(|| ctx.config.get("GENESIS_REGISTRY_TOKEN_SECRET").cloned()) + .or_else(|| ctx.config.get("genesis_git_token_secret").cloned()) + .or_else(|| ctx.config.get("GENESIS_GIT_TOKEN_SECRET").cloned()) + .or_else(|| ctx.config.get("genesis_token_secret").cloned()) + .or_else(|| ctx.config.get("GENESIS_TOKEN_SECRET").cloned()); + let secret_names = configured_secret_name + .as_deref() + .map(|name| vec![name.trim().to_string()]) + .unwrap_or_else(|| vec!["GENESIS_GIT_TOKEN".to_string(), "GENESIS_TOKEN".to_string()]); + + let mut failures = Vec::new(); + for secret_name in secret_names.iter().filter(|name| !name.is_empty()) { + match ctx.get_secret(secret_name) { + Ok(token) if !token.trim().is_empty() => { + return Ok(format!("Authorization: Bearer {}", token.trim())); + } + Ok(_) => failures.push(format!("{secret_name}: empty")), + Err(error) => failures.push(format!("{secret_name}: {error}")), + } + } + + Err(format!( + "Genesis git publish requires an active Genesis GitToken secret; attempted {}", + failures.join("; ") + )) +} + fn genesis_registry_headers(registry_tenant: &str) -> Vec<(String, String)> { let mut headers = internal_headers(); headers.push(("X-Tenant-Id".to_string(), registry_tenant.to_string())); diff --git a/os-apps/paw-agent/wasm/monty_repl/src/entity_ops.rs b/os-apps/paw-agent/wasm/monty_repl/src/entity_ops.rs index d0af111d2..bb1e07cb3 100644 --- a/os-apps/paw-agent/wasm/monty_repl/src/entity_ops.rs +++ b/os-apps/paw-agent/wasm/monty_repl/src/entity_ops.rs @@ -2437,6 +2437,29 @@ fn pawfs_entity_id(value: &Value) -> Option<&str> { entity_field_str_any(value, &["entity_id", "Id", "id"]) } +fn pawfs_stable_entity_id(prefix: &str, ws_id: &str, path: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in prefix + .bytes() + .chain([0]) + .chain(ws_id.bytes()) + .chain([0]) + .chain(path.bytes()) + { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{prefix}-{hash:016x}") +} + +fn is_pawfs_lookup_too_large(error: &str) -> bool { + error.contains("QueryTooLarge") +} + +fn is_pawfs_already_exists_error(error: &str) -> bool { + error.contains("409") || error.contains("already exists") || error.contains("AlreadyExists") +} + fn entity_field_i64_any(value: &Value, keys: &[&str]) -> Option { for key in keys { if let Some(found) = value.get(*key).and_then(Value::as_i64) { @@ -2529,7 +2552,7 @@ fn find_pawfs_directory( api_url, tenant, eid, - &format!("/tdata/Directories?$filter={filter}"), + &format!("/tdata/Directories?$filter={filter}&$top=1"), )?; Ok(pawfs_first_entity(&resp).and_then(pawfs_directory_from_value)) } @@ -2549,7 +2572,7 @@ fn find_pawfs_file( api_url, tenant, eid, - &format!("/tdata/Files?$filter={filter}"), + &format!("/tdata/Files?$filter={filter}&$top=1"), )?; Ok(pawfs_first_entity(&resp).and_then(pawfs_file_from_value)) } @@ -2563,8 +2586,10 @@ fn create_pawfs_directory( path: &str, name: &str, parent_id: Option<&str>, + directory_id: &str, ) -> Result { let mut body = json!({ + "Id": directory_id, "Name": name, "Path": path, "WorkspaceId": ws_id, @@ -2573,10 +2598,11 @@ fn create_pawfs_directory( body["ParentId"] = json!(parent_id); } - let resp = http_post(ctx, api_url, tenant, principal_id, "/tdata/Directories", &body)?; - let id = pawfs_entity_id(&resp) - .ok_or_else(|| "temper.pawfs(): Directory created but no Id returned".to_string())? - .to_string(); + let id = match http_post(ctx, api_url, tenant, principal_id, "/tdata/Directories", &body) { + Ok(resp) => pawfs_entity_id(&resp).unwrap_or(directory_id).to_string(), + Err(error) if is_pawfs_already_exists_error(&error) => directory_id.to_string(), + Err(error) => return Err(error), + }; Ok(PawFsDirectory { id, name: name.to_string(), @@ -2594,56 +2620,67 @@ fn ensure_pawfs_directory( raw_path: &str, ) -> Result { let normalized = pawfs_normalize_path(raw_path)?; - if let Some(directory) = find_pawfs_directory(ctx, api_url, tenant, ws_id, &normalized)? { - return Ok(directory); - } - - let mut parent = match find_pawfs_directory(ctx, api_url, tenant, ws_id, "/")? { - Some(directory) => directory, - None => create_pawfs_directory(ctx, api_url, tenant, principal_id, ws_id, "/", "/", None)?, + let segments = if normalized == "/" { + vec!["/"] + } else { + pawfs_path_segments(&normalized) }; - - if normalized == "/" { - return Ok(parent); - } - + let mut parent: Option = None; let mut current_path = String::new(); - for segment in pawfs_path_segments(&normalized) { - current_path.push('/'); - current_path.push_str(segment); + for segment in segments { + let (path, name) = if segment == "/" { + ("/".to_string(), "/") + } else { + current_path.push('/'); + current_path.push_str(segment); + (current_path.clone(), segment) + }; - if let Some(directory) = find_pawfs_directory(ctx, api_url, tenant, ws_id, ¤t_path)? { - parent = directory; + let existing = match find_pawfs_directory(ctx, api_url, tenant, ws_id, &path) { + Ok(directory) => directory, + Err(error) if is_pawfs_lookup_too_large(&error) => None, + Err(error) => return Err(error), + }; + if let Some(directory) = existing { + parent = Some(directory); continue; } + let parent_id = parent.as_ref().map(|directory| directory.id.as_str()); + let directory_id = pawfs_stable_entity_id("dr", ws_id, &path); let directory = create_pawfs_directory( ctx, api_url, tenant, principal_id, ws_id, - ¤t_path, - segment, - Some(&parent.id), + &path, + name, + parent_id, + &directory_id, )?; - if let Err(error) = http_post( - ctx, - api_url, - tenant, - principal_id, - &format!("/tdata/Directories('{}')/Temper.AddChild", parent.id), - &json!({}), - ) { - ctx.log( - "warn", - &format!("temper.pawfs(): AddChild failed on directory {}: {error}", parent.id), - ); + if let Some(parent) = &parent { + if let Err(error) = http_post( + ctx, + api_url, + tenant, + principal_id, + &format!("/tdata/Directories('{}')/Temper.AddChild", parent.id), + &json!({}), + ) { + ctx.log( + "warn", + &format!( + "temper.pawfs(): AddChild failed on directory {}: {error}", + parent.id + ), + ); + } } - parent = directory; + parent = Some(directory); } - Ok(parent) + parent.ok_or_else(|| format!("temper.pawfs(): could not ensure directory {normalized}")) } fn ensure_pawfs_file( @@ -2976,6 +3013,17 @@ mod tests { ); } + #[test] + fn pawfs_stable_directory_ids_are_path_scoped() { + let first = pawfs_stable_entity_id("dr", "katagami", "/proofs"); + let second = pawfs_stable_entity_id("dr", "katagami", "/proofs"); + let other = pawfs_stable_entity_id("dr", "katagami", "/other"); + + assert_eq!(first, second); + assert!(first.starts_with("dr-")); + assert_ne!(first, other); + } + #[test] fn pawfs_agent_tools_do_not_call_workspace_filesystem_actions() { let source = include_str!("entity_ops.rs"); diff --git a/os-apps/paw-agent/wasm/wasm-helpers/src/lib.rs b/os-apps/paw-agent/wasm/wasm-helpers/src/lib.rs index 56267ceb1..b7678de1a 100644 --- a/os-apps/paw-agent/wasm/wasm-helpers/src/lib.rs +++ b/os-apps/paw-agent/wasm/wasm-helpers/src/lib.rs @@ -16,6 +16,7 @@ const TEMPERFS_WRITE_ATTEMPTS: usize = 5; const TEMPERFS_BATCH_READ_ATTEMPTS: usize = 3; const SESSION_ENTRIES_PAGE_SIZE: usize = 1000; const SESSION_ENTRIES_MAX_PAGES: usize = 100; +const SESSION_ENTRIES_MAX_CHAIN_DEPTH: usize = 2000; #[derive(Debug, Clone, PartialEq, Eq)] pub struct BatchTextFileReadItem { @@ -90,6 +91,10 @@ pub fn is_session_entries_ref(reference: &str) -> bool { session_id_from_entries_ref(reference).is_some() } +fn is_virtual_first_turn_session_leaf(session_id: &str, session_leaf_id: &str) -> bool { + session_leaf_id == format!("u-{session_id}-0") +} + pub fn next_session_entry_id(prefix: &str, parent_entry_id: &str) -> (String, i64) { let next_sequence = parent_session_entry_sequence(parent_entry_id).unwrap_or(0) + 1; (format!("{prefix}-{next_sequence}"), next_sequence) @@ -221,8 +226,29 @@ pub fn read_session_from_temperfs( tenant: &str, fields: &Value, file_id: &str, +) -> Result { + read_session_from_temperfs_with_leaf(ctx, temper_api_url, tenant, fields, file_id, None) +} + +pub fn read_session_from_temperfs_with_leaf( + ctx: &Context, + temper_api_url: &str, + tenant: &str, + fields: &Value, + file_id: &str, + session_leaf_id: Option<&str>, ) -> Result { if let Some(session_id) = session_id_from_entries_ref(file_id) { + if let Some(leaf_entry_id) = session_leaf_id.filter(|value| !value.is_empty()) { + return read_session_from_entries_chain( + ctx, + temper_api_url, + tenant, + fields, + session_id, + leaf_entry_id, + ); + } return read_session_from_entries(ctx, temper_api_url, tenant, fields, session_id); } @@ -721,7 +747,7 @@ fn session_entries_list_url( skip: usize, ) -> String { format!( - "{temper_api_url}/tdata/SessionEntries?$filter=SessionId%20eq%20%27{}%27&$top={top}&$skip={skip}", + "{temper_api_url}/tdata/SessionEntries?$filter=SessionId%20eq%20%27{}%27&$orderby=Sequence%20asc,EntryId%20asc&$top={top}&$skip={skip}", session_id.replace('\'', "''"), ) } @@ -783,6 +809,69 @@ pub fn read_session_from_entries( Ok(session_entries_jsonl_from_entities(&entries)) } +fn read_session_from_entries_chain( + ctx: &Context, + temper_api_url: &str, + tenant: &str, + fields: &Value, + session_id: &str, + session_leaf_id: &str, +) -> Result { + if is_virtual_first_turn_session_leaf(session_id, session_leaf_id) { + return Ok(String::new()); + } + + let headers = runtime_headers(ctx, tenant, fields, None, Some("application/json")); + let mut entries = Vec::new(); + let mut current_entry_id = session_leaf_id.to_string(); + + for depth in 0..SESSION_ENTRIES_MAX_CHAIN_DEPTH { + let url = session_entry_verify_url(temper_api_url, session_id, ¤t_entry_id); + let resp = ctx.http_call("GET", &url, &headers, "")?; + if resp.status != 200 { + return Err(format!( + "SessionEntry chain read failed for EntryId={current_entry_id} (HTTP {}): {}", + resp.status, + &resp.body[..resp.body.len().min(300)] + )); + } + + let parsed: Value = serde_json::from_str(&resp.body) + .map_err(|err| format!("parse SessionEntry chain response: {err}"))?; + let mut page = parsed + .get("value") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if page.is_empty() { + if entries.is_empty() { + return Ok(String::new()); + } + return Err(format!( + "SessionEntry parent EntryId={current_entry_id} was not visible while walking session {session_id}" + )); + } + + let entry = page.remove(0); + let parent_entry_id = entity_field_str(&entry, &["ParentEntryId", "parent_entry_id"]) + .unwrap_or("") + .to_string(); + entries.push(entry); + if parent_entry_id.is_empty() { + return Ok(session_entries_jsonl_from_entities(&entries)); + } + current_entry_id = parent_entry_id; + + if depth + 1 == SESSION_ENTRIES_MAX_CHAIN_DEPTH { + break; + } + } + + Err(format!( + "SessionEntry chain exceeded {SESSION_ENTRIES_MAX_CHAIN_DEPTH} entries for session {session_id} leaf {session_leaf_id}; refusing incomplete context" + )) +} + pub fn sync_session_entries_from_jsonl( ctx: &Context, temper_api_url: &str, @@ -1863,10 +1952,17 @@ mod tests { fn session_entries_list_url_pages_with_skip() { assert_eq!( session_entries_list_url("http://temper", "ss-1", 1000, 2000), - "http://temper/tdata/SessionEntries?$filter=SessionId%20eq%20%27ss-1%27&$top=1000&$skip=2000" + "http://temper/tdata/SessionEntries?$filter=SessionId%20eq%20%27ss-1%27&$orderby=Sequence%20asc,EntryId%20asc&$top=1000&$skip=2000" ); } + #[test] + fn virtual_first_turn_leaf_is_detected_without_odata_probe() { + assert!(is_virtual_first_turn_session_leaf("ss-live", "u-ss-live-0")); + assert!(!is_virtual_first_turn_session_leaf("ss-live", "a-2")); + assert!(!is_virtual_first_turn_session_leaf("ss-live", "u-other-0")); + } + #[test] fn next_session_entry_id_advances_numeric_suffix() { assert_eq!(next_session_entry_id("a", "u-1"), ("a-2".to_string(), 2)); diff --git a/os-apps/paw-fs/wasm/artifact_batch_apply/src/lib.rs b/os-apps/paw-fs/wasm/artifact_batch_apply/src/lib.rs index 7e486d8e0..3dd5ef6a4 100644 --- a/os-apps/paw-fs/wasm/artifact_batch_apply/src/lib.rs +++ b/os-apps/paw-fs/wasm/artifact_batch_apply/src/lib.rs @@ -306,7 +306,7 @@ fn find_entity_id( ctx, api_url, tenant, - &format!("/tdata/{set_name}?$filter={encoded}"), + &format!("/tdata/{set_name}?$filter={encoded}&$top=1"), )?; Ok(resp .get("value")