diff --git a/.github/scripts/verify-release-artifacts.sh b/.github/scripts/verify-release-artifacts.sh index b35a74651..a6b344e16 100644 --- a/.github/scripts/verify-release-artifacts.sh +++ b/.github/scripts/verify-release-artifacts.sh @@ -23,17 +23,17 @@ require_exactly_one_path() { } require_exactly_one_file() { - local file_name="$1" count=0 matched_path="" - while IFS= read -r -d '' matched_path; do count=$((count + 1)); done < <(find "$artifact_root" -type f -name "$file_name" -print0) + local directory="$1" file_name="$2" count=0 matched_path="" + while IFS= read -r -d '' matched_path; do count=$((count + 1)); done < <(find "$artifact_root/$directory" -type f -name "$file_name" -print0) if [[ $count -ne 1 ]]; then - printf 'Expected exactly one release artifact named %s, found %s.\n' "$file_name" "$count" >&2 + printf 'Expected exactly one release artifact named %s in %s, found %s.\n' "$file_name" "$directory" "$count" >&2 exit 1 fi } expected_dirs=( "release-disksage-ubuntu-22.04-${run_attempt}" - "release-disksage-windows-latest-${run_attempt}" + "release-disksage-windows-2022-${run_attempt}" "release-disksage-macos-latest-${run_attempt}" ) @@ -55,22 +55,24 @@ if [[ -n "$unexpected_entry" ]]; then exit 1 fi -require_exactly_one_path '*/bundle/deb/*.deb' 'Debian bundle' -require_exactly_one_path '*/bundle/appimage/*.AppImage' 'AppImage bundle' -require_exactly_one_path '*/bundle/msi/*.msi' 'Windows MSI bundle' -require_exactly_one_path '*/bundle/nsis/*.exe' 'Windows NSIS bundle' -require_exactly_one_path '*/bundle/dmg/*.dmg' 'macOS DMG bundle' +require_exactly_one_path "$artifact_root/${expected_dirs[0]}/bundle/deb/*.deb" 'Debian bundle' +require_exactly_one_path "$artifact_root/${expected_dirs[0]}/bundle/appimage/*.AppImage" 'AppImage bundle' +require_exactly_one_path "$artifact_root/${expected_dirs[1]}/bundle/msi/*.msi" 'Windows MSI bundle' +require_exactly_one_path "$artifact_root/${expected_dirs[1]}/bundle/nsis/*.exe" 'Windows NSIS bundle' +require_exactly_one_path "$artifact_root/${expected_dirs[2]}/bundle/dmg/*.dmg" 'macOS DMG bundle' -for required_name in \ - disksage-cloud-plan-linux-x86_64 \ - disksage-duplicate-audit-linux-x86_64 \ - disksage-cloud-plan-windows-x86_64.exe \ - disksage-duplicate-audit-windows-x86_64.exe \ - disksage-cloud-plan-macos-arm64 \ - disksage-duplicate-audit-macos-arm64; do - require_exactly_one_file "$required_name" - require_exactly_one_file "$required_name.sha256" -done +require_exactly_one_file "${expected_dirs[0]}" disksage-cloud-plan-linux-x86_64 +require_exactly_one_file "${expected_dirs[0]}" disksage-cloud-plan-linux-x86_64.sha256 +require_exactly_one_file "${expected_dirs[0]}" disksage-duplicate-audit-linux-x86_64 +require_exactly_one_file "${expected_dirs[0]}" disksage-duplicate-audit-linux-x86_64.sha256 +require_exactly_one_file "${expected_dirs[1]}" disksage-cloud-plan-windows-x86_64.exe +require_exactly_one_file "${expected_dirs[1]}" disksage-cloud-plan-windows-x86_64.exe.sha256 +require_exactly_one_file "${expected_dirs[1]}" disksage-duplicate-audit-windows-x86_64.exe +require_exactly_one_file "${expected_dirs[1]}" disksage-duplicate-audit-windows-x86_64.exe.sha256 +require_exactly_one_file "${expected_dirs[2]}" disksage-cloud-plan-macos-arm64 +require_exactly_one_file "${expected_dirs[2]}" disksage-cloud-plan-macos-arm64.sha256 +require_exactly_one_file "${expected_dirs[2]}" disksage-duplicate-audit-macos-arm64 +require_exactly_one_file "${expected_dirs[2]}" disksage-duplicate-audit-macos-arm64.sha256 checksum_files=() checksum_file="" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 980672cd7..30a69e363 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -259,6 +259,10 @@ jobs: path: release-artifacts merge-multiple: false + - name: Verify downloaded release artifact contract + shell: bash + run: bash .github/scripts/verify-release-artifacts.sh release-artifacts "${{ github.run_attempt }}" + - name: Generate and validate source-bound SBOM shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf76f051..1fef59b34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Show the last read-only iCloud File Provider evidence timestamp beside the new-copy admission state, so a stalled `no progress`/`hard expired` queue has an actionable retry context without exposing provider paths. +- Include the redacted iCloud File Provider `pending-indexable-count` in admission evidence and + surface `icloud-file-provider-indexing-pending` when Finder remains in “복사 준비 중”. +- Record the redacted File Provider `disk import: yes` marker as + `icloud-file-provider-disk-import-active`, show it beside the iCloud admission evidence, and + keep Finder copy, attestation, and source cleanup blocked while macOS is importing a provider + disk. +- Persist the earliest retained timestamp for an unchanged iCloud admission-blocker set, so a + restart cannot reset the stalled-copy duration; this diagnostic never grants copy, attestation, + or eviction authority. +- Persist bounded, path-free OneDrive/Google Drive provider-global observations and return + `admission_blocked_since_ms` for an unchanged blocker cohort, so a Finder “복사 준비 중” stall + remains visible across DiskSage or system restarts; tampered evidence is ignored and the journal + never grants copy, attestation, or source-eviction authority. - Bind Tauri packaging to a fail-closed cross-manifest release-version verifier so `package.json`, `Cargo.toml`, `tauri.conf.json`, and any `v*` release tag must agree on one valid Semantic Version before a bundle is built. - Add retry-safe release concurrency: fresh first attempts may supersede stale runs, while explicit GitHub rerun attempts do not self-cancel inside the same concurrency group. - Replace generator-era Cargo package metadata with the DiskSage product description, MIT license expression, canonical source repository URL, and `publish = false` registry-publication boundary; deliberately omit Cargo's deprecated `authors` field, verify publication refusal through Cargo's versioned parsed metadata rather than substring matching, and regression-test commented/out-of-table decoys together with the retained acquisition metadata and doctoring evidence. @@ -52,6 +65,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Fixed +- Consume the persisted iCloud `admission_blocked_since_ms` diagnostic in the UI stall clock, so a + system or application restart preserves the visible duration of an unchanged provider block; + durable evidence remains advisory and fail-closed. - Reject ontology organize destinations that are relative to the process working directory, named-user tilde paths, or parent-traversal paths; only an absolute destination or a home token (`~`/`~/`, plus native Windows `~\`) can produce a move plan, and literal tildes in absolute @@ -64,6 +80,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Keep the shipped Naruon readiness verifier source includable by its integration boundary test; the terminal parser contract now compiles in both the binary and test-module contexts. +- Bound numeric File Provider disk-full markers for `errno`, `odresult_errno`, and + `OSStatus -34`, so longer codes such as `errno 280` cannot be misclassified as + local-disk-full evidence. + +- Include `icloud-file-provider-indexing-pending` in the Naruon iCloud admission-blocker binding, + so a signed non-iCloud envelope cannot smuggle that provider blocker through validation. + - Cover the `sensitive-config` archive-kind wire label in the generated cloud-plan implementation, so the macOS/Linux/Windows cloud-plan binaries compile after the sensitive-config safety boundary is enabled. diff --git a/docs/architecture/adr/0001-cloud-offload-goal-state.md b/docs/architecture/adr/0001-cloud-offload-goal-state.md index 1ccd9f466..45a6abf1e 100644 --- a/docs/architecture/adr/0001-cloud-offload-goal-state.md +++ b/docs/architecture/adr/0001-cloud-offload-goal-state.md @@ -529,12 +529,50 @@ new-copy admission blocker (`icloud-file-provider-filename-excluded` or The Finder preparation dialog therefore remains an incomplete provider operation, not a successful copy receipt, and copy, attestation, and eviction stay fail-closed until the provider is quiet. -## Amendment: keep the readiness verifier boundary testable (2026-08-22) - -The shipped Naruon readiness verifier uses a plain source comment rather than a crate-inner doc -comment so the same parser can be included by its integration boundary test module. This is a -compile-boundary repair only; the verifier's path-redacted output and readiness authority do not -change. +## Amendment: current iCloud indexing and transfer receipt (2026-08-21 22:22 +0900) + +A fresh bounded read-only `fileproviderctl` observation completed at `2026-08-21 22:22:53 +0900`. +The path-free aggregate reported `needs-indexing=no`, `pending-indexable-count=13,737`, a +12,449-entry reconciliation backlog, one active upload marker, one active download marker with +99.16% observed progress, one no-progress fetch, and 18 filename plus 2 root sync exclusions. +The parser retained `icloud-file-provider-indexing-pending`, transfer, no-progress, and exclusion +admission blockers; the bounded dump was truncated and no mutation was performed. This is still +provider-sync-incomplete evidence: the Finder preparation dialog is not a completed copy receipt, +and native copy, attestation, and eviction remain blocked. + +## Amendment: bounded planning and attestation-retention edge cases (2026-08-21) + +Exact-content duplicate clusters are now computed before the presentation `limit` is applied. A +duplicate pair split across that limit therefore still marks the visible member for canonical +selection and remains represented in the path-free cluster summary; the limit controls presentation, +not safety evidence. The retention pass also protects the just-written immutable provider record +when its clock is older than the existing bounded history, so clock regression cannot delete the +attestation that was just persisted. Both boundaries remain fail-closed and are covered by focused +Rust regression tests. The local implementation is `c5aa3a1`; its protected-branch publication is +still pending the repository ruleset's normal PR workflow. + +## Amendment: iCloud indexing backlog is an admission blocker (2026-08-21) + +A repeated read-only File Provider observation remained unchanged for 21 seconds: the provider +reported `pending-indexable-count=12474` and upload progress `0/5038` at `0.0000`, alongside the +existing 18 filename and 2 root exclusions. DiskSage now retains this aggregate count in the +path-free activity evidence and adds `icloud-file-provider-indexing-pending` to new-copy blockers; +the UI includes it in the stable-block fingerprint and warns that Finder may remain in “복사 준비 +중”. No Finder/provider process is killed and no cloud or source mutation is performed. The +extension remains additive to activity schema v3 and is covered by a parser regression test. + +## Amendment: restart-safe iCloud admission duration (2026-08-21) + +Each successful iCloud health inspection now persists a bounded, path-free observation before +deriving `admission_blocked_since_ms` from the earliest contiguous retained record with the same +admission-blocker set. Invalid, unreadable, or changed historical evidence stops the walk, so a +restart cannot manufacture a longer stall interval. The field is diagnostic only: provider-native +completion, copy receipts, attestation, and local eviction remain independent fail-closed gates. +The implementation and regression test are at source head `ad850e9`. + +The Naruon readiness allow-list now includes `icloud-file-provider-indexing-pending`, keeping the +provider-derived blocker set closed under export and rejecting the same blocker on non-iCloud +envelopes. The binding repair is at source head `6f95ca3`. ## Amendment: bound active-use probes without touching provider state (2026-08-22) @@ -547,6 +585,408 @@ focused Rust regression test passed 3/3. A timeout remains incomplete active-use keeps cache cleanup and cloud eviction fail-closed; this process-group cleanup is not a provider recovery or copy-cancellation operation. +## Amendment: keep the readiness verifier boundary testable (2026-08-22) + +The shipped Naruon readiness verifier uses a plain source comment rather than a crate-inner doc +comment so the same parser can be included by its integration boundary test module. This is a +compile-boundary repair only; the verifier's path-redacted output and readiness authority do not +change. + +## Amendment: exact numeric disk-full markers (2026-08-21) + +Provider-global File Provider parsing now applies numeric-boundary matching to `errno 28`, +`odresult_errno 28`, and `OSStatus -34` in addition to the existing `code=28` forms. Longer +values such as `errno 280` and `OSStatus -340` remain ordinary provider errors and cannot create +the actionable local-disk-full blocker. The focused regression covers both exact and extended +markers; this parser remains diagnostic evidence only and does not grant copy, attestation, or +eviction authority. + +## Amendment: live Finder preparation stall receipt (2026-08-21 23:30 +0900) + +The exact-head headless iCloud health probe completed a bounded read-only observation with complete +evidence. macOS reported `needs-sync-up` and `needs-sync-down`; File Provider reported one +no-progress fetch, active upload/download progress (`953100`/`988500` millionths), 17,547 pending +indexable items, 18 filename exclusions, and two root exclusions. The CloudDocs upload queue retained +six items blocked on sync-up. New-copy admission is therefore `blocked`. These aggregate facts explain +why Finder can remain at “복사 준비 중”, but they do not identify or attest the seven displayed items. + +DiskSage records only bounded, path-free counters and exposes the existing Finder cancellation +request. It does not kill `fileproviderd`, `bird`, or Finder, modify CloudDocs/provider state, or +convert this observation into copy, attestation, or eviction authority. A complete quiet observation +and independent per-item provider evidence remain required. This evidence was observed while PR #246 +was at `fc9f4a4c465fc5ef355f7fbf552ff4295cf4f609` and PR #247 at +`45214018dff43c6ba7c71253bc50e8c0eab0e1bd`; hosted checks remain authoritative. + +## Amendment: detect macOS File Provider disk import during Finder preparation (2026-08-22) + +A bounded read-only iCloud File Provider dump can report `disk import: yes` while Finder remains +in “복사 준비 중”. DiskSage now retains only the boolean aggregate as the +`icloud-file-provider-disk-import-active` notice, derives the same new-copy admission blocker, +and surfaces it with the existing fixed Finder-cancel action. The notice contains no path, +filename, item identifier, or raw provider output. Disk import is provider-progress evidence, not +a copy receipt; copy, attestation, and source eviction remain fail-closed until a complete quiet +observation and independent per-item evidence exist. + +## Amendment: isolate third-party stall clocks and preserve prior onset on journal faults (2026-08-24) + +One shared path-free `provider-global-sync-evidence` journal can contain interleaved OneDrive and +Google Drive observations. The restart-safe onset walk therefore ignores valid records belonging to +another provider instead of treating them as a blocker transition. Read, parse, integrity, or +incomplete-record failures stop the walk while retaining the onset already accumulated from newer +valid records; they can never manufacture a longer duration. Rust regressions cover both an +interleaved provider record and a malformed older record. This is diagnostic continuity only: it +does not grant copy, attestation, cloud mutation, or source eviction authority. + +## Amendment: unchanged iCloud preparation queue after restart (2026-08-22 04:05 +0900) + +A subsequent bounded, read-only observation found the same iCloud File Provider aggregate state: +`pending-indexable-count=31,024`, upload progress `5,202,024,494/5,462,125,152` (95.24%), +download progress `0/828`, and `disk import: yes`. The native sync summary still reported +`needs-sync-up`/`needs-sync-down` with the last sync at `2026-08-21 20:20:10.166 +0900`; multiple +items remained in `pending-scan` for roughly three or more hours. The unchanged counters are +provider-stall evidence, not a per-item receipt and not proof that the visible Finder operation +completed. DiskSage performed no Finder cancellation, daemon restart, CloudDocs/provider-database +write, cloud mutation, materialization, or source mutation. New copy, attestation, and eviction +therefore remain fail-closed; the existing bounded Finder-cancel action remains operator initiated. + +## Amendment: current protected PR inventory is evidence-bound (2026-08-22 04:23 +0900) + +The product baseline records the exact protected PR queue at DiskSage head `dac324d` (PR #247). +That inventory is operational evidence only: each row binds its own head SHA, and a later push +invalidates predecessor checks and approvals. A clean mergeable flag, bot comment, or queued review +never authorizes a cloud copy, provider attestation, source eviction, or protected merge. The +review loop remains exact-head review → repair → checks → qualifying approval → normal protected +merge; no provider, Finder, cloud, or user-file mutation was performed for this amendment. + +## Amendment: preserve third-party Finder-stall duration across restart (2026-08-24) + +The screenshot-level symptom “복사 준비 중” can outlive both Finder and DiskSage. The existing +third-party provider-global probe now stamps each bounded OneDrive/Google Drive observation and +persists a path-free `ProviderGlobalSyncEvidenceSnapshot` under +`provider-global-sync-evidence`. Create-only, SHA-256-fingerprinted records are capped at 64 KiB, +stored as `0400` files in a `0700` directory, and retained at most 128 records. Invalid, tampered, +incomplete, or unsafe records cannot extend a blocker interval. + +When the same provider, state, aggregate transfer/indexing flags, and stable blocker set are seen +again, the command returns `admission_blocked_since_ms`; CloudArchive uses it as the third-party +stall-clock origin after an application or system restart. The persisted clock is diagnostic only: +it does not cancel Finder, restart a provider, write cloud data, attest an item, or authorize source +eviction. A provider-global `-1004`/disconnect, active transfer, reconciliation backlog, or local +disk-full marker therefore remains a visible fail-closed blocker until a fresh complete quiet +observation and independent per-item evidence exist. + +## Amendment: executed iCloud admission evidence for the current Finder stall (2026-08-24 12:39 +0900) + +The exact-head `disksage-icloud-sync-health` binary completed a read-only CloudDocs/WAL snapshot +with complete evidence. It observed `needs-sync-up|needs-sync-down`, 343 uploads blocked on +sync-up, one active upload at 95.24%, one active download, and 58,183 pending indexable items. +New-copy admission is `blocked` for transfer activity, disk import, indexing backlog, root and +filename exclusions, and native sync-up/down state. The report explicitly retains +`provider_sync_attested=false`, `local_eviction_authorized=false`, and `mutation_performed=false`. + +This is global provider evidence explaining Finder's “복사 준비 중” state, not a per-item cloud +receipt. The probe reads a copy-on-write snapshot including WAL files, redacts paths, and never +writes CloudDocs/provider state. Copy, attestation, and source eviction remain fail-closed until +quiet provider evidence and independent per-item receipts exist. + +## Amendment: reject impossible persisted stall-onset values (2026-08-24 12:51 +0900) + +CloudArchive treats the persisted blocker onset as diagnostic input, not trusted authority. It now +accepts that value only when it is a safe, non-negative integer no later than the backend observation; +negative, future, non-finite, and unsafe-integer values fall back to the current observation. This +prevents malformed history from suppressing a prolonged Finder “복사 준비 중” warning while keeping +copy, attestation, cloud-write, and source-eviction authority fail-closed. + +## Amendment: latest iCloud preparation queue remains blocked (2026-08-24 12:58 +0900) + +The latest exact-head read-only probe still reports `new_copy_admission_state=blocked` and +`mutation_performed=false`. Native iCloud remains `needs-sync-up|needs-sync-down`; 343 uploads are +blocked on sync-up, one upload and one download are active, and pending indexable items increased +to 64,969 from 58,183 at 12:39. Disk import, transfer activity, and the filename/root exclusions +remain present. This aggregate provider evidence explains the Finder preparation stall but is not a +per-item receipt, so copy, attestation, cloud-write, and source-eviction authority remain +fail-closed; DiskSage performs no Finder, provider, source, or cloud mutation. + +## Amendment: iCloud indexing backlog increased during the Finder stall (2026-08-24 13:04 +0900) + +A subsequent exact-head read-only probe observed the same `needs-sync-up|needs-sync-down` native +state, 343 uploads blocked on sync-up, one active upload at 95.24%, one active download, and +`new_copy_admission_state=blocked`. FileProvider pending indexable items increased from 64,969 to +67,017 while disk import, transfer activity, and the filename/root exclusions remained present. +The evidence is aggregate and `provider_sync_attested=false`, `local_eviction_authorized=false`, +and `mutation_performed=false`; therefore it cannot attest the seven Finder items or authorize any +copy, cloud write, or source eviction. + +## Amendment: iCloud backlog continues to grow without a DiskSage database handle (2026-08-24 13:12 +0900) + +The next exact-head read-only probe observed `pending_indexable_count=74,946` (up from 67,017), +the same native `needs-sync-up|needs-sync-down` state, 343 uploads blocked on sync-up, one active +upload at 95.24%, one active download, and `new_copy_admission_state=blocked`. Finder's +`real_datasets` destination remained 512 bytes with the same 2026-08-20 03:28:07 mtime and the +root had about 99 GiB available. Bounded process inspection found `fileproviderd` using 72–129% CPU, +but no DiskSage process, CloudDocs database, or source path was open in that process. This supports +a provider-side reconciliation/indexing backlog, not a proven DiskSage database lock. The probe +still reports `provider_sync_attested=false`, `local_eviction_authorized=false`, and +`mutation_performed=false`; no Finder, provider, source, or cloud mutation is authorized. + +## Amendment: preview headroom follows the destination staging filesystem (2026-08-24 13:22 +0900) + +The planner previously exposed source-volume pressure while the native mutation gate correctly +probed the destination staging ancestor. That could make a cross-volume preview disagree with the +actual copy boundary. The planner now performs the same bounded destination probe for each visible, +otherwise-unblocked candidate and emits `local-volume-headroom-insufficient` or +`local-volume-headroom-unverified`; the native UI gate follows those notices, while explicit +provider-API uploads remain a separate path. The source-volume snapshot remains diagnostic only. +Pinned Rust tests, the destination-headroom contract, the full frontend suite (134 tests), +`svelte-check`, and frontend coverage all pass; mutation, attestation, and eviction authority are +unchanged and still fail closed. + +## Amendment: reconcile cwd-relative organize targets with current main (2026-08-24 18:28 +0900) + +PR #225 exact head `ea6f82d914e4660319600acb614fccb4a701aec1` now contains the current `main` +lineage-aware organization contract and the original fail-closed target fix. `resolve_target_folder` +expands only an exact `~` or leading `~/` against an absolute home path; process-cwd-relative, +named-user tilde, parent/root traversal, and relative-home targets are rejected. Organization plans +retain embedded-metadata-first production evidence (filename tokens such as `2026-04-28`/`251210` +remain secondary), source size/mtime, and a lineage fingerprint; source drift is rechecked before +execution. + +The exact-head local proof is 21 `organize::tests`, one Windows home-resolution contract test, +`actionlint`, and `git diff --check`. Hosted checks were restarted for this head and remain +authoritative; the draft PR has no qualifying approval. This amendment changes no Finder/provider, +cloud, source-file, or eviction state. + +## Amendment: make platform fixtures and active-use CI evidence explicit (2026-08-24 18:51 +0900) + +PR #225 exact head `3715a5ada760072d3675026fe7f264b4ee47964f` keeps the resolver fail-closed on +Windows by changing only the regression fixtures from POSIX `/home/u` to the existing platform +absolute-home helper. PR #227 exact head `753352a1d0cd7e297bb656d5edf9339235a628a3` installs `lsof` +in the Ubuntu test image so active-use evidence remains complete in CI; production behavior still +fails closed when the probe is unavailable. Local proofs are 21/21 organize tests and 735/735 Rust +tests with one ignored live-provider test. These CI/fixture repairs grant no copy, cloud-write, +attestation, source-eviction, Finder-cancel, or provider-restart authority. + +## Amendment: re-confirm the live Finder preparation blocker without mutation (2026-08-24 19:04 +0900) + +A fresh read-only `/usr/bin/brctl status` still reports iCloud `client:needs-sync` with +`needs-sync-up|in-sync-down|prefer-sync-down|oob-sync-ack`, 1,740 `pending-scan` entries, and +343 `pending-sync-up` entries. Finder, `fileproviderd`, and `bird` are present, while no DiskSage +process is running. The root volume has about 12 GiB available, so global fullness is not proven; +the screenshot's seven-item copy size and destination receipt remain unknown. This is aggregate +provider reconciliation evidence only: `provider-sync-incomplete`, copy/attestation, cloud-write, +and source-eviction authority remain fail-closed, and no Finder, provider, source, or cloud +mutation was performed. + +## Amendment: exact-head health receipt keeps new copy admission blocked (2026-08-24 19:10 +0900) + +The exact-head `disksage-icloud-sync-health` probe completed as a read-only report with +`evidence_complete=true`, `new_copy_admission_state=blocked`, and +`pending_indexable_count=151283`; one upload is active at 95.24% and one download is active. +The report records 343 uploads blocked on sync-up and retains +`provider_sync_attested=false`, `local_eviction_authorized=false`, and `mutation_performed=false`. +The aggregate receipt cannot attest the seven Finder items or a remote cloud write, so copy, +cloud-write, and source-eviction authority remain fail-closed. + +## Amendment: record the exact-head Git worktree audit compile repair (2026-08-24 19:28 +0900) + +DiskSage #249 exact head `c8ca669262f913de5719ebda377132f1135c06c8` repairs the hosted +all-features `E0425` by making the library-owned `MAX_REFERENCE_BYTES` bound available to its CLI +unit tests without duplicating the validation contract. Pinned Rust 1.97.1 proofs passed 7/7 CLI +tests and 10/10 black-box Git-worktree tests. The audit remains read-only and path-redacted; no +worktree removal, Finder/provider operation, cloud write, or source eviction is authorized by this +repair. + +## Amendment: recheck the Finder preparation blocker after scheduler recovery (2026-08-24 19:38 +0900) + +A fresh read-only `/usr/bin/brctl status` still reports the iCloud client as `needs-sync` with +`needs-sync-up|in-sync-down|prefer-sync-down|oob-sync-ack`; the last native sync remains +`2026-08-21 20:20:10.166`. Finder, `fileproviderd`, and `bird` are running, while no DiskSage +process is present. The root volume has about 12 GiB available. This is provider-global +reconciliation evidence consistent with the multi-hour Finder `real_datasets` preparation stall, +not a per-item receipt and not proof of a DiskSage lock or cloud write. The existing +`provider-sync-incomplete` admission, copy/attestation, and source-eviction gates therefore stay +fail-closed; no Finder, provider, source, or cloud mutation was performed. + +## Amendment: expose a path-free lineage graph for catalog handoff (2026-08-24 19:52 +0900) + +The CloudArchive receipt view now offers a JSON export only when a verified receipt contains the +modern lineage fingerprint. The export is a bounded client-side graph with stable content, +metadata, archive, provider, receipt, Goal, and optional eviction node identifiers; it carries +production-time source/confidence, provider sync state, and sorted blockers, while explicitly +setting `local_paths_included=false`. It never fabricates a provider item, attestation, Goal +completion, or eviction relation: legacy receipts without lineage are not exportable, and an +eviction edge appears only after the real eviction output exists. This is an export/view action +only; it performs no provider, source, cloud, ADR, or Goal mutation. + +When a provider attestation contains a remote object proof, the graph adds a path-free +`provider-item` node and binds it to the provider and receipt. Without that proof the provider +item remains explicitly absent rather than inferred from a local File Provider path. + +## Amendment: preserve the live Finder preparation diagnosis (2026-08-24 20:00 +0900) + +The latest read-only `/usr/bin/brctl status` still reports iCloud `client:needs-sync` with +`needs-sync-up|needs-sync-down|in-sync-down|prefer-sync-down|oob-sync-ack`; the native last-sync +timestamp remains `2026-08-21 20:20:10.166`. Finder, `fileproviderd`, and `bird` are running, but +no DiskSage process is present. The persistent `pending-scan` queue and missing per-item receipt +keep the Finder `real_datasets` preparation operation at `provider-sync-incomplete`; no +provider, source, cloud, or Finder mutation was performed. + +## Amendment: keep native copy headroom candidate-scoped (2026-08-24 20:18 +0900) + +Destination-volume headroom is now evaluated per candidate during the Rust plan stage. A file that +does not fit (or whose destination probe is unavailable) receives its own +`blocked_reason`, while smaller candidates whose probe passes remain eligible; the aggregate plan +notice remains for operator visibility. The UI preserves fail-closed behavior for legacy reports +that have only the aggregate notice. This prevents one oversized archive from disabling every +otherwise admissible copy and does not grant copy, cloud-write, attestation, or source-eviction +authority. + +The replaceable Goal contract now names `provider-sync-incomplete` as an explicit runtime state and +`destination-headroom-bound` as a completion gate. These terms keep the Finder/File Provider +diagnosis and candidate-scoped local staging evidence visible to projections instead of collapsing +both into a generic pending state. + +## Amendment: classify native iCloud pending scans (2026-08-24 21:00 +0900) + +The bounded `brctl status` parser now counts path-free `apply{[ pending-scan ... ]}` entries as +`pending_scan_count` and emits `icloud-native-status-pending-scan`. This is an aggregate native +provider observation, not a per-item cloud receipt: a Finder “복사 준비 중” dialog remains +unverified, `provider-sync-incomplete`, and blocked for copy, attestation, cloud write, and source +eviction until the scan backlog is gone and item-level provider evidence is present. The Naruon +readiness export and CloudArchive UI carry the same blocker and show the bounded next action. + +## Amendment: persist native health blockers in runtime projections (2026-08-25 00:00 +0900) + +When an iCloud admission probe is persisted, DiskSage now selects the native pending-scan blocker +when present and applies it to every bounded, valid iCloud receipt projection. The replaceable Goal +is written as `blocked` with `provider-sync-state-complete=false` and +`explicit-eviction-permit=false`; the paired ADR records `provider-state-blocked:`. This +is a projection update only: immutable receipts remain authoritative, and no provider, Finder, +source, cloud, attestation, or eviction mutation is performed. A missing, malformed, oversized, +or incomplete receipt set emits a stable projection warning instead of claiming that all Goals were +updated. + +## Amendment: current Google Drive preparation diagnosis (2026-08-25 09:23 +0900) + +A fresh bounded read-only `fileproviderctl dump com.google.drivefs.fpext -l` identified the provider +shown behind the `real_datasets` Finder dialog as temporarily disconnected. The dump reported File +Provider `-1004` server-unreachable failures for the root metadata fetch, active upload and download +progress markers, a 2,000-entry reconciliation section, and a provider error generation above zero. +The local Google Drive mount exposed no materialized `real_datasets` destination at observation time; +the 7.2 GiB source remained local and unchanged. The root volume had about 2.1 GiB available, so a +retry that might stage the source locally is unsafe even though this particular dump did not emit an +explicit disk-full marker. + +System Events did not enumerate an active Finder copy-progress window during the later read-only +check. That absence is not evidence of a completed copy: no destination receipt or remote content +proof exists. The default route through `utun4` is recorded as network context only, not as a proven +root cause. DiskSage therefore keeps `provider-global-sync-temporarily-disconnected`, +`provider-global-sync-server-unreachable`, `provider-global-sync-transfer-active`, and +`provider-global-sync-reconciliation-pending` fail-closed for copy, attestation, and source +eviction. No Finder cancellation, provider restart, cloud write, source mutation, or eviction was +performed. The observation was made against DiskSage PR #247 exact head +`9fdf2922da2939d96d3c2393539f2b2d42009929`; the PR remains draft, review-required, and blocked while +hosted checks are pending. + +## Amendment: project third-party provider blockers into runtime Goal/ADR (2026-08-25 09:30 +0900) + +The provider-global sync persistence path now reuses the monotonic projection helper for OneDrive +and Google Drive as well as iCloud. A fresh `temporarily disconnected`, server-unreachable, +transfer-active, or reconciliation-pending observation therefore writes the matching receipt-linked +Goal as `blocked`, closes `provider-sync-state-complete` and `explicit-eviction-permit`, and records +`provider-state-blocked:` in its paired ADR. A clear report never rewrites projections, and +missing or malformed receipts remain an explicit bounded warning. This is local evidence/projection +state only; no Finder cancellation, provider restart, cloud write, source mutation, attestation, or +eviction was executed. + +After reclaiming only DiskSage's disposable Rust build artifacts, the root volume had about 3.5 GiB +free, while the same Google Drive dump still reported `temporarily disconnected`, `-1004`, active +transfer markers, and a 2,000-entry reconciliation section. The persisted diagnosis is therefore +not reduced to local disk pressure. The implementation was verified at PR #247 exact head +`87c9089bcd4af49f8f8751c54ebcc45b519d1f0c`; the draft PR remains review-required and blocked while +hosted checks are pending. + +## Amendment: bind headroom evidence to the actual data volume (2026-08-25 10:14 +0900) + +The live host recheck distinguished the system volume from the `/Users` data volume used by the +source and File Provider staging. `/Users` had about 594 MiB available before disposable build +artifacts were cleaned, while `real_datasets` was about 7.2 GiB; after cleanup the same data volume +had about 2.7 GiB available. The iCloud dump simultaneously reported `pending-indexable-count: +490195`, upload/download progress entries stuck at `0.0000`, and a 482,470-entry reconciliation +section. DiskSage +therefore treats destination-volume headroom and provider-global state as independent blockers: +`local-volume-headroom-insufficient` remains candidate-specific, while provider indexing/transfer +blockers remain global. A system-root `df` result cannot authorize a copy staged on `/Users`. + +The preview adapter releases only unverified destination-ancestor diagnostics so a later mutation +probe remains authoritative; insufficient headroom stays blocked. This preserves the existing +legacy aggregate fallback and gives the UI candidate-scoped evidence without granting cloud-write, +attestation, or source-eviction authority. Observation is read-only; no Finder, provider, source, +or cloud mutation was performed. Exact implementation head: `5c3b87359103b82df3efb4099668b1b17f532259`. + +## Amendment: repeated zero-progress iCloud receipt (2026-08-25 10:18 +0900) + +Two read-only probes 19 seconds apart observed `pending-indexable-count` rising from `492224` to +`492507` and reconciliation from `484500` to `484783`; both retained upload and download markers +at `Fraction completed: 0.0000`. No standalone `cp`, `ditto`, or `rsync` process was present; the +visible preparation window is therefore attributed to Finder/File Provider coordination, not a +DiskSage copy worker. This is a repeated provider-stall receipt: Goal remains +`provider-sync-incomplete`, and copy, attestation, cloud-write, and source-eviction gates remain +closed. No cancellation or provider/source/cloud mutation was performed. + +## Amendment: deterministic preview-headroom regression fixture (2026-08-25 11:00 +0900) + +The candidate-scoped preview normalization behavior is unchanged. Its regression fixture now uses +an intentionally unfit candidate size so the test cannot inherit the host runner's root-volume +capacity when the synthetic destination has no existing ancestor. Pinned Rust 1.97.1 verification +passed all 745 library tests plus one ignored live-provider test at PR #247 exact head +`dc57a1539b82514f4ceb17ec0fca42ed23ae7988`; no cloud, provider, Finder, source, or eviction +mutation was performed. + +## Amendment: current Finder copy-preparation provider receipt (2026-08-25 11:06 +0900) + +A fresh read-only File Provider dump identifies the visible Finder preparation as a provider +coordination stall, not a DiskSage copy worker. The Google Drive domain is `temporarily +disconnected`; its root metadata fetch reports File Provider `-1004` (server unreachable), the +reconciliation queue is capped at 2,000 entries, and the latest user-initiated root retry is about +57 minutes old. Upload/download progress markers exist without a completed item receipt. The +iCloud domain independently reports `pending-indexable-count: 505103`, upload/download progress +at `0.0000`, `disk import: yes`, and 497,379 reconciliation entries. The data volume currently has +about 20 GiB free, so the observed Finder wait is not itself proof of local disk exhaustion. + +DiskSage therefore keeps the operation at `provider-sync-incomplete`: the Finder “복사 준비 중” +window is not a cloud-write receipt, and copy, attestation, source eviction, provider restart, and +Finder cancellation remain fail-closed. No Finder, provider, source, cloud, or eviction mutation +was performed. The evidence is read-only and path-free; filename dates remain secondary to embedded +metadata and context. + +## Amendment: persistent provider stall recheck (2026-08-25 11:09 +0900) + +The next bounded read-only recheck still reports the same two provider blockers. Google Drive is +temporarily disconnected with File Provider `-1004`, a 2,000-entry reconciliation cap, and active +upload/download markers. iCloud has grown to `pending-indexable-count: 506044` and 498,320 +reconciliation entries while upload/download remain at `0.0000`; disk import and stream reset are +still active. The data volume remains at approximately 20 GiB free. This confirms persistence of +the provider coordination stall rather than a transient Finder rendering issue. The runtime Goal +remains `provider-sync-incomplete`; no copy, attestation, eviction, Finder cancellation, provider +restart, or cloud/source mutation was performed. + +## Amendment: third-party provider indexing can expose bounded Finder cancellation (2026-08-25 11:24 +0900) + +The provider-global UI now treats `provider-global-sync-indexing-pending` as a Finder-copy blocker, +alongside active transfer and reconciliation blockers. This closes the case where OneDrive or Google +Drive reports only an indexing backlog: the user can request the existing bounded Finder Escape +action, while cloud/provider/source mutation remains unchanged. The contract test and Svelte type +check pass at exact head `dda0f1d5`; no automatic cancellation was performed. + +## Amendment: retain unverified destination blockers until proof exists (2026-08-25) + +Cloud-plan presentation now checks for at least one previously unblocked candidate with verified +destination/staging headroom before clearing any `local-volume-headroom-destination-*` diagnostic. +When no candidate proves the staging filesystem, the candidate blocker and plan-wide fail-closed +notice remain, so the serialized backend view cannot advertise a copy-only approval phrase for an +unverified destination. The focused unit and runtime regressions pass at exact head +`b3e00c6a9bf13152562ccc50f2ed742b03f0bffa`; mutation-time re-probing remains authoritative. ## Amendment: current iCloud Finder preparation evidence (2026-08-25) The current bounded read-only observation retained three `fetchContentsForItemWithID` requests diff --git a/docs/architecture/adr/0006-redacted-icloud-health-evidence.md b/docs/architecture/adr/0006-redacted-icloud-health-evidence.md index 70b5b0cc3..18d4f7411 100644 --- a/docs/architecture/adr/0006-redacted-icloud-health-evidence.md +++ b/docs/architecture/adr/0006-redacted-icloud-health-evidence.md @@ -38,12 +38,19 @@ The timestamped records are the third evidence stream alongside `volume-pressure `provider-client-runtime-evidence`. iCloud plans combine the three records with the bounded freshness comparator in [ADR-0007](0007-pre-copy-evidence-cohort.md); a missing, incomplete, malformed, or skewed stream remains blocked without reconstructing a provider dump. +After the current observation is written, the command returns the earliest retained timestamp for +the same admission-blocker set as `admission_blocked_since_ms`. The UI uses that diagnostic value +when starting its stall clock, falling back to the current observation only when durable evidence +is unavailable. This preserves a visible stall duration across an application or system restart; +it never changes copy, attestation, or eviction authority. ## Consequences ### Positive - The current iCloud incident remains comparable after a restart or UI refresh. +- A restarted UI retains the provider stall duration when the bounded evidence journal is readable, + instead of presenting a long-running Finder preparation as a newly observed block. - Provider evidence is durable without copying private provider databases or raw output. - Bounded create-only records preserve provenance and fail closed on malformed claims. - The UI can tell the operator when current evidence was observed and when durable comparison failed. @@ -69,3 +76,263 @@ malformed, or skewed stream remains blocked without reconstructing a provider du - [ADR-0001](0001-cloud-offload-goal-state.md) — provider evidence and fail-closed eviction gates. - [ADR-0005](0005-hourly-agent-loop-is-advisory.md) — scheduled loops remain advisory and cannot authorize mutation. + +## Operational evidence update — 2026-08-24 + +The post-restart bounded observation recorded `pending-indexable-count=32377`, a `28123`-entry +reconciliation queue, upload progress `6229217391/6540678102`, `scheduling state: running`, +`disk import: yes`, and `stream reset: yes`; `brctl` still reported `needs-sync-up|needs-sync-down`. +These aggregate values are incident evidence only. They do not identify a `real_datasets` item or +prove a cloud write, so the existing decision continues to require per-item provider evidence and +keeps copy, attestation, and source eviction fail-closed. + +The same bounded observation also captured File Provider activity while the Finder dialog remained +at “preparing to copy” for hours: iCloud continued redacted item ingestion, while a separate +Google Drive File Provider request returned `NSFileProviderErrorDomain -1004` (device cannot +connect to the server) during root materialization. The provider name is therefore part of the +diagnosis; a Finder progress window alone cannot tell which provider is stalled. DiskSage records +this as provider-specific runtime evidence, exposes the existing explicit Finder-cancel action, +and never infers copy completion or grants eviction authority from the dialog. + +## Operational evidence update — 2026-08-24 11:34 + +A later bounded read-only observation increased the aggregate iCloud queue to +`pending-indexable-count=39404` and `reconciliation=35150` while the same upload counter remained +at `6229217391/6540678102` (95.24%), with `scheduling state: running`, `disk import: yes`, and +`stream reset: yes`. `brctl` still reported `needs-sync-up|needs-sync-down` and pending scans were +about 55 hours old. This worsening aggregate state reinforces the existing fail-closed decision; +it still does not bind the Finder `real_datasets` dialog to an item-level cloud write. + +## Operational evidence update — 2026-08-24 13:53 + +A bounded local recheck at `13:48:21 +0900` found about 96 GiB free on the root volume while Finder, +`fileproviderd`, and `bird` had remained alive for roughly three hours. The visible `real_datasets` +target remained 512 bytes with mtime `2026-08-20 03:28:07 +0900`; no target handle appeared in the +bounded process-handle sample. The latest complete iCloud health receipt available for this loop +reported 343 uploads blocked on sync-up, one active upload at 95.24%, one active download, and +74,946 pending indexable items. These facts are aggregate provider evidence, not per-item cloud +attestation. The decision therefore remains unchanged: DiskSage reports the reconciliation/indexing +backlog, offers only the explicit bounded Finder-cancel action, and keeps copy, attestation, and +source eviction fail-closed. No provider process, CloudDocs database, source, or cloud object was +mutated. + +## Operational evidence update — 2026-08-24 14:11 + +The exact-head `disksage-icloud-sync-health` binary completed another read-only CloudDocs/WAL +snapshot with `evidence_complete=true` and `new_copy_admission_state=blocked`. Aggregate upload +backlog remained 343 items blocked on sync-up and one active upload remained at 95.24%; File +Provider pending indexable items increased from 74,946 to 103,013 while one download and the +disk-import/transfer notices remained active. The `real_datasets` target still had 14 entries, +512 bytes, and mtime `2026-08-20 03:28:07 +0900`, with about 94 GiB available on `/`. + +The observation remains supplementary global provider evidence. It does not identify a Finder item +or attest a cloud write, so `provider_sync_attested=false`, `local_eviction_authorized=false`, and +`mutation_performed=false` remain required. DiskSage continues to expose only the explicit bounded +Finder-cancel action and never restarts provider processes or mutates provider, source, or cloud +state from this evidence. + +## Operational evidence update — 2026-08-24 14:31 + +A fresh read-only health receipt observed `evidence_complete=true` and +`new_copy_admission_state=blocked`. Aggregate upload state remained 343 items blocked on sync-up +with one active upload at 95.24%; one active download and File Provider indexing, disk-import, and +transfer activity remained, while pending indexable items increased to 110,652. Native status +continued to report `client_state=needs-sync` with sync-up/down pending, and filename/root +exclusions were still present. + +The root volume had about 83 GiB available and a bounded `lsof` sample found no handle on the +`real_datasets` target while Finder remained at “preparing to copy”. This is provider +reconciliation/indexing evidence, not disk exhaustion or per-item cloud-write proof. The decision +is unchanged: keep `provider_sync_attested=false`, `local_eviction_authorized=false`, and +`mutation_performed=false`; expose only the explicit bounded Finder-cancel action and never +restart providers or mutate provider, source, or cloud state from this aggregate receipt. + +## Operational evidence update — 2026-08-24 14:55 + +The next bounded read-only CloudDocs/WAL snapshot completed with +`evidence_complete=true` and `new_copy_admission_state=blocked`. The upload queue still contained +343 items blocked on sync-up; one active upload remained at 95.24% and one active download was +present. File Provider pending indexable items increased to 121,859, with the same disk-import, +transfer, filename-exclusion, and root-exclusion notices. Native status continued to report +`client_state=needs-sync` and sync-up/down pending. + +The root volume still had 66 GiB available, the 14-entry `real_datasets` directory remained 512 +bytes with its 2026-08-20 mtime, and the bounded `lsof` sample found no handle on that directory. +This is a worsening provider reconciliation/indexing backlog, not local disk exhaustion or +per-item cloud-write proof. The existing decision therefore remains fail-closed: +`provider_sync_attested=false`, `local_eviction_authorized=false`, and `mutation_performed=false`. + +## Operational evidence update — 2026-08-24 15:34 + +The next bounded read-only receipt still reported `evidence_complete=true` and +`new_copy_admission_state=blocked`. The 343-item sync-up backlog and one active upload at 95.24% +were unchanged, while File Provider pending indexable items increased to 128,917; one download, +disk import, transfer activity, and the 28 filename/2 root exclusions remained present. Native +status continued to report `client_state=needs-sync` with `needs-sync-up|in-sync-down|prefer-sync-down|oob-sync-ack`. + +This increasing aggregate queue is stronger provider-stall evidence but still cannot identify the +seven Finder items or prove a cloud write. The observation remains read-only and keeps +`provider_sync_attested=false`, `local_eviction_authorized=false`, and `mutation_performed=false`. + +## Operational evidence update — 2026-08-24 15:46 + +The latest bounded read-only receipt still reported `evidence_complete=true` and +`new_copy_admission_state=blocked`. The sync-up backlog remained 343 items and the active upload +remained at 95.24%; one active download remained. File Provider pending indexable items increased +again to 130,571, while disk import, transfer activity, and the 28 filename/2 root exclusions +remained present. Native status continued to report `client_state=needs-sync` with sync-up pending. + +The growing aggregate backlog is consistent with the Finder “preparing to copy” stall, but it does +not identify the seven Finder items or attest a cloud write. DiskSage therefore continues to keep +`provider_sync_attested=false`, `local_eviction_authorized=false`, and `mutation_performed=false`; +the probe performed no Finder, provider, source, or cloud mutation. + +## Decision maintenance — 2026-08-24 16:03 + +The latest product review queue keeps the same safety decision: #247 is ready for review at +`59057c08eb5017ac57b640419a0c7e4779f443d7`, but queued checks and protected approvals are not yet +complete. The health evidence remains diagnostic only; no readiness, review, or queue state can +promote aggregate iCloud evidence into per-item upload attestation or local-eviction authority. + +## Operational evidence update — 2026-08-24 16:03 + +The next bounded read-only receipt still reports `evidence_complete=true` and +`new_copy_admission_state=blocked`. The sync-up backlog remains 343 items; one upload remains at +95.24% and one download remains active. Pending File Provider indexable items reached 131,214, +with disk import, transfer activity, and the 28 filename/2 root exclusions still present. Native +status remains `client_state=needs-sync` with `needs-sync-up`. + +The aggregate queue continues to grow, but the receipt still does not identify the seven Finder +items or attest a remote write. `provider_sync_attested=false`, `local_eviction_authorized=false`, +and `mutation_performed=false` remain invariant. + +## Decision maintenance — 2026-08-24 17:49 + +The exact-head PR #247 integration run exposed and repaired a test-fixture defect in the mixed +destination-headroom regression. The unsafe symlink is now placed at the actual dated destination +ancestor derived by the same Rust production-date decomposition used by the planner; the verified +media candidate remains eligible while the unsafe document candidate remains diagnostically +partial. The focused suite passed 11/11 on Rust 1.97.1. No provider, Finder, source, or cloud +mutation rule changed. + +## Decision maintenance — 2026-08-24 17:59 + +The Finder-copy cancellation control now tells the operator why macOS Accessibility/System Events +permission is required to send the fixed Escape request, and explicitly states that a denied request +does not mutate files or cloud data. The focused UI contract/privacy tests passed 6/6 and +`npm run check` reported zero diagnostics. This is explanatory UX only; provider admission, +attestation, and eviction remain fail-closed. + +## Operational evidence update — 2026-08-24 16:21 + +The latest bounded read-only receipt still reports `evidence_complete=true` and +`new_copy_admission_state=blocked`. The sync-up backlog remains 343 items; one upload remains +active at 95.24% and one download remains active. File Provider pending indexable items increased +to 132,783, while disk-import, transfer, filename-exclusion, and root-exclusion notices remain. +Native status remains `client_state=needs-sync` with sync-up pending. + +This is provider-global reconciliation evidence consistent with Finder remaining at “preparing to +copy”, but it neither identifies the seven items nor proves that DiskSage is holding a Finder lock +or that a cloud write completed. `provider_sync_attested=false`, `local_eviction_authorized=false`, +and `mutation_performed=false` remain required; no Finder, provider, source, or cloud mutation was +performed. + +## Decision maintenance — 2026-08-24 16:32 + +The exact-head review loop repaired two independent safety/documentation findings without changing +the iCloud fail-closed decision: #246 restored the coverage dead-code allowance to +`node_navigation` (head `1972614`), and #227 renamed the bound audit parameter to `stable_root` +(head `5ad1197`) while retaining the intentionally nested private module contract. Both focused +Rust test slices passed locally; hosted checks and protected approvals remain authoritative gates. + +## Decision maintenance — 2026-08-24 16:47 + +The exact-head loop also repaired #249's process-test storage gap at head `db95c54`: the three +feature-gated Git-worktree CLI integration tests now reuse deterministic private target directories +and remove stale output before each nested build, preventing process-id-named target accumulation. +This test-only cleanup does not alter provider, source, Finder, or cloud mutation boundaries. + +## Decision maintenance — 2026-08-24 16:50 + +The current-head review queue was refreshed after the accessibility and compiler-baseline PRs were +marked ready: #203 is at `5f0bd51`, #244 at `13caeb0`, and #249 at `db95c54`. All remain blocked by +live hosted gates and protected approvals; none of these states changes the provider evidence +decision or authorizes source/cloud mutation. + +## Operational evidence update — 2026-08-24 16:50 + +The latest bounded read-only receipt still reports `evidence_complete=true` and +`new_copy_admission_state=blocked`. The 343-item sync-up backlog, one active upload at 95.24%, one +active download, native `client_state=needs-sync`, and sync-up pending remain unchanged. Pending +File Provider indexable items increased to 135,334. The receipt remains aggregate provider evidence +only; `provider_sync_attested=false`, `local_eviction_authorized=false`, and +`mutation_performed=false` remain invariant. + +## Decision maintenance — 2026-08-24 16:55 + +The current #249 exact head is `aa5c37d`. Its test-only target helper now keeps concurrent +process-scoped build directories while pruning dead-process or aged stale output; this preserves +the disk-reclamation goal without changing any provider, Finder, source, or cloud mutation rule. + +## Operational evidence update — 2026-08-24 17:00 + +A fresh read-only `/usr/bin/brctl status` completed at 17:00. The iCloud container reports +`client:needs-sync` and `sync:needs-sync-up|in-sync-down|prefer-sync-down|oob-sync-ack`; the +bounded summary contains 1,740 `pending-scan` entries, 343 `pending-sync-up` entries, 1,807 +scheduled sync-up markers, and 5 upload errors. Several queued uploads have not run for roughly +60–66 hours, including `CKErrorDomain:4` “Saving asset failed” records. + +This is provider-global reconciliation/error evidence consistent with the Finder +`real_datasets` “복사 준비 중” dialog persisting for hours. It does not identify the seven Finder +items or attest a cloud write, so the evidence remains diagnostic only: +`provider_sync_attested=false`, `local_eviction_authorized=false`, and `mutation_performed=false`. +DiskSage must continue to expose only the explicit bounded Finder-cancel action and must not +restart provider processes or mutate Finder, source, or cloud state automatically. The root volume +had about 36 GiB available at the same observation, so disk-full is not the current root cause. +At 17:02, a read-only process inventory showed Finder (PID 1422), `fileproviderd` (1450), and +`bird` (1462) all started at 10:43:49, about 6h18m earlier. This confirms a long-lived provider +session but does not establish DiskSage ownership or a Finder lock. + +## Decision maintenance — 2026-08-24 17:33 + +The exact-head PR #249 test repair is now `dc9ccf2`. Its three process-contract tests use Cargo's +`CARGO_BIN_EXE_disksage-git-worktree-audit` instead of launching nested feature-gated builds; +the focused slices passed 8/8, 2/2, and 1/1, with no new `disksage-git-worktree-*` temporary +targets created. This removes a local test-side source of disk pressure without changing the +provider, Finder, source, or cloud mutation boundaries. The PR is draft, blocked, review-required, +with hosted checks pending and no unresolved review threads. + +## Decision maintenance — 2026-08-24 17:38 + +At exact head `b8a17eb`, retained iCloud health snapshots now accept the exact pre- +`pending_indexable_count` fingerprint encoding when that optional field is absent. New snapshots +still use the current fingerprint, and the 29-test iCloud health slice passed on Rust 1.97.1. This +preserves the restart-safe stall clock across upgrades without weakening evidence integrity or +changing the fail-closed provider/Finder/source/cloud mutation boundary. + +## Operational evidence update — 2026-08-24 17:40 + +A fresh bounded read-only `/usr/bin/brctl status` still reports `client:needs-sync` with +`pending-scan=1,740`, `pending-sync-up=343`, and `sync-up-scheduled=2,150`; 20 lines matched the +bounded upload-error/“Saving asset failed” markers. Finder, `fileproviderd`, and `bird` remain the +same long-lived provider session started at 10:43:49. The root volume currently has about 21 GiB +available (926 GiB total, 12 GiB used), so this is not a full-root condition, but headroom is +lower than the earlier 36 GiB observation. The Finder copy remains diagnostic-only: no item-level +remote write is identified, and `provider_sync_attested=false`, `local_eviction_authorized=false`, +and `mutation_performed=false` remain invariant. + +## Operational evidence update — 2026-08-24 21:00 + +The native status contract now retains only a bounded `pending_scan_count` derived from +`brctl status` apply markers and exposes the stable blocker +`icloud-native-status-pending-scan`. It never persists the marker's path or item identifier. The +same blocker is validated in Naruon readiness and displayed beside the Finder cancellation +guidance; it does not authorize cancellation, cloud writes, attestation, or source eviction. + +## Runtime projection update — 2026-08-25 00:00 + +The iCloud health persistence path now propagates the selected bounded blocker to existing iCloud +receipt-linked Goal/ADR projections. Goal status and completion gates therefore reflect the current +provider-sync hold after restart or a manual health inspection, while receipt/evidence authority is +unchanged. Projection directory, receipt contents, and provider identifiers are not included in +the emitted notices. diff --git a/docs/architecture/adr/0011-cloud-transfer-failure-and-materialization.md b/docs/architecture/adr/0011-cloud-transfer-failure-and-materialization.md index dbf2787bc..ca72945b2 100644 --- a/docs/architecture/adr/0011-cloud-transfer-failure-and-materialization.md +++ b/docs/architecture/adr/0011-cloud-transfer-failure-and-materialization.md @@ -1,6 +1,6 @@ # ADR-0011: Failed copy evidence and placeholder-safe adoption -**Status:** Accepted +**Status:** Accepted **Date:** 2026-08-25 ## Context diff --git a/docs/architecture/goals/cloud-offload-goal.json b/docs/architecture/goals/cloud-offload-goal.json index a10ea466b..c1a06f7ac 100644 --- a/docs/architecture/goals/cloud-offload-goal.json +++ b/docs/architecture/goals/cloud-offload-goal.json @@ -6,12 +6,14 @@ "states": [ "copy-verified", "pending-provider-sync", + "provider-sync-incomplete", "provider-sync-confirmed", "eviction-ready", "source-evicted" ], "completion_gates": [ "metadata-and-lineage-bound", + "destination-headroom-bound", "copy-content-verified", "provider-sync-state-complete", "immutable-evidence-record-valid", @@ -23,9 +25,10 @@ "pre_copy_evidence_streams": [ "volume-pressure-evidence", "provider-client-runtime-evidence", - "icloud-sync-health-evidence" + "icloud-sync-health-evidence", + "provider-global-sync-evidence" ], - "pre_copy_evidence_rule": "compare one canonical three-stream cohort; missing, malformed, incomplete, or skewed evidence remains blocked", + "pre_copy_evidence_rule": "compare one canonical evidence cohort; missing, malformed, incomplete, or skewed evidence remains blocked", "pre_copy_evidence_max_skew_ms": 300000, "runtime_evidence_failure_policy": "fail-closed; unavailable provider-client runtime evidence is not process absence", "operator_actions": [ diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5ecd46866..8afdd7680 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,9 +1,9 @@ # DiskSage product and technical gap baseline -**Snapshot:** 2026-08-22 (Asia/Seoul) -**Repository heads at snapshot:** PR #213 `a6ec6e2`, PR #247 `a0fa7bc`, PR #246 `741ab30`, -supporting PR #156 `39a08a7`, and PR #192 `30ceea2`; hosted checks and protected review remain -authoritative, and no merge is claimed from queued or stale status. +**Snapshot:** 2026-08-24 18:51 +0900 (Asia/Seoul) +**Repository heads at snapshot:** the dated inventory and 18:30 correction below supersede earlier +historical captures; hosted checks and protected review remain authoritative, and no merge is +claimed from queued, stale, or bot-only status. **Product boundary:** local-first macOS disk pressure relief with iCloud, OneDrive, and Google Drive destinations. **Evidence rule:** this document is a dated baseline, not an authority for transfer or deletion. Runtime receipts, provider attestations, object identity, and current GitHub checks remain authoritative. @@ -15,12 +15,38 @@ authoritative, and no merge is claimed from queued or stale status. 4. Regenerable caches are a separate reclaim domain. They are per-child, identity-bound, active-use checked, journaled, and moved to OS Trash; they are not uploaded as user data. 5. Deterministic Rust gates own safety. A local model may judge only the fixed maintenance command after dry-run evidence, calibration, and explicit human confirmation. No external LLM or OAuth service is a runtime prerequisite for the standalone product. +## 2026-08-24 18:11 +0900 current protected PR inventory + +This is the current review queue captured from GitHub immediately before this snapshot. A commit +SHA is authoritative only for the PR row where it appears; a later push invalidates predecessor +checks and approvals. + +| PR | Exact head | Draft | Merge state | Review state | Current interpretation | +| --- | --- | --- | --- | --- | --- | +| #249 | `dc9ccf2a215061fba5bea2a23e8df3e84a0cd072` | yes | blocked | review required | Git worktree audit help; process tests use Cargo's shipped binary without nested temp builds | +| #247 | `e4cfd1ce84148f490a94e0093e59a9ce9fb2f735` | yes | blocked | review required | iCloud provider indexing plus live Finder/provider stall evidence; dated headroom-fixture repair, explicit Finder Accessibility permission guidance, and dynamic ADR maintenance | +| #246 | `1972614ee5488cca34deeb3bd999d369c61b3de1` | no | blocked | review required | Storybook/accessibility contract; iCloud stall clock test slice is 7/7 | +| #244 | `13caeb04333e50e57c8a51a11b64aeb131c080b2` | no | blocked | review required | Rust 1.97.1 compiler baseline | +| #204 | `5bf86a9c593888fe5f08bff9f8dea74e5f1299ae` | yes | blocked | review required | DiskSage shell/icon identity; every Test/Release Node bootstrap and zlib ABI pinned after hosted runtime mismatch | +| #227 | `753352a1d0cd7e297bb656d5edf9339235a628a3` | yes | blocked | review required | symlink-root audit hardening; Ubuntu active-use tests now install `lsof` | +| #225 | `3715a5ada760072d3675026fe7f264b4ee47964f` | yes | blocked | review required | cwd-relative organize targets fail closed; Windows regressions use platform-absolute homes | +| #212 | `75d728e403cf0b30511e149a7e650731f6472733` | no | blocked | review required | cloud operational CLI help | +| #206 | `2e7b845b7610a871ec5981d964bcab5cb99df41d` | no | clean | none | content-bound Homebrew execution; no qualifying approval | +| #205 | `5c86668a6e503a174ff0b07151f67226b39547ff` | no | clean | none | Intel Homebrew target support; no qualifying approval | +| #203 | `5f0bd51be4b2faca8a30aadc661bf651a619c549` | no | blocked | review required | TopFiles accessibility contract | +| #202 | `ec2db50307d0d6bccd2546a820c7a6822f054df5` | no | blocked | review required | bounded scan/navigation failure feedback | +| #189 | `66d7aa767d416048a752c5c550e8d64e03213e0e` | no | blocked | review required | Homebrew cleanup status UI | + +Additional draft dependency/security PRs remain open and are not merge candidates. No protected +merge is inferred from `clean`, green predecessor checks, bot comments, or queued reviews. The queue +is processed exact-head-first: review, repair, recheck, then normal protected merge. + ## Buyer-observable product gaps | Priority | Gap / observable symptom | Evidence | Acceptance criterion | | --- | --- | --- | --- | -| P0 | Cloud offload can remain blocked while a provider is syncing or reports `local-current`/`is_uploaded=false`; the user sees no safe reclaim despite free cloud capacity. | Existing provider-global and iCloud native-state gates; `bird`/`fileproviderd` remain active during the current incident, with about 3.8 GiB available at the latest observation. | UI explains the exact blocker, last evidence time, and next bounded retry; a verified provider attestation alone can advance a candidate, never a stale projection. | -| P0 | A long Finder/provider copy can appear hung and consume the remaining local headroom. | The `real_datasets` Finder copy remained at “준비 중” for hours; the latest bounded iCloud dump retained 125 no-progress fetch/create markers, a 95.24% upload, and a zero-progress 1.06GB download while scheduling was `running`. Bounded `/bin/cp`/`mkdir` and global probes use private process groups and headroom gates. | Preview shows required bytes + staging reserve; timeout cleans only the child-created destination and leaves a durable receipt. | +| P0 | Cloud offload can remain blocked while a provider is syncing or reports `local-current`/`is_uploaded=false`; the user sees no safe reclaim despite free cloud capacity. | Existing provider-global and iCloud native-state gates; `bird`/`fileproviderd` remain active during the current incident, while the root volume has about 96 GiB available. | UI explains the exact blocker, last evidence time, and next bounded retry; a verified provider attestation alone can advance a candidate, never a stale projection. | +| P0 | A long Finder/provider copy can appear hung and consume the remaining local headroom. | A repeated exact-head iCloud dump remained unchanged for 21 seconds with `pending-indexable-count=12474`, upload `0/5038` at `0.0000`, active upload/download markers, and 18 filename plus 2 root exclusions. | UI reports the indexing backlog and stable blocker duration; Finder copy, attestation, and eviction remain fail-closed until a fresh quiet provider observation. | | P1 | Personal desktop-client capacity is not the same as API quota; OAuth is unnecessarily implied for a single-user installation. | ADR-0001 permits copy-only desktop-client mode marked `capacity-unverified`; the cloud connection UI defaults to read-only OAuth consent and requires an explicit write-access opt-in. | Settings clearly distinguish local desktop client, API quota, and organization OAuth; no OAuth prompt is required for the local-only path. | | P1 | Users cannot yet see a full lineage graph connecting source, metadata, archive member, provider item, receipt, Goal, and eviction decision. | The candidate UI now exposes a compact source→metadata→archive→provider lineage panel using the stable fingerprint, confidence, and blocker state; provider item/receipt/permit remain explicitly pending until their evidence exists. | Export and UI show stable content IDs, provenance edges, confidence, and blockers without exposing raw private paths. | | P1 | “Orphan”/duplicate cleanup is difficult to trust because relationship evidence is not visible before action. | Ontology and duplicate/orphan PRs are open; current default path remains fail-closed. | Every proposed removal has an explainable parent/child/duplicate relation, identity recheck, reversible Trash action, and a no-candidate result when evidence is incomplete. | @@ -31,10 +57,11 @@ authoritative, and no merge is claimed from queued or stale status. | Priority | Gap | Current state | Smallest next proof | | --- | --- | --- | --- | | P0 | Provider end-to-end receipt is absent for the current iCloud incident. | Global probe can time out and CloudDocs state is intentionally not force-killed or deleted; the native copy boundary now requires an integrity-checked three-stream pre-copy cohort before mutation. | Capture a bounded fresh provider evidence receipt after sync settles; keep transfer/eviction disabled until it is complete. | -| P0 | Disk pressure telemetry and provider queue evidence must remain comparable across loops without retaining raw provider output. | Cloud plans and explicit iCloud health refreshes persist bounded, path-free `LocalVolumeSnapshot`, `ProviderClientRuntimeSnapshot`, and `IcloudSyncHealthEvidenceSnapshot` records under `volume-pressure-evidence`, `provider-client-runtime-evidence`, and `icloud-sync-health-evidence`; iCloud plans now combine them into a timestamp/fingerprint-bound cohort. | Missing, incomplete, malformed, or more-than-five-minute-skewed cohort observations remain blocked; a fresh exact-head native incident plan is still needed to compare the emitted cohort with the live incident. | +| P0 | Disk pressure telemetry and provider queue evidence must remain comparable across loops without retaining raw provider output. | Cloud plans and explicit provider health refreshes persist bounded, path-free `LocalVolumeSnapshot`, `ProviderClientRuntimeSnapshot`, `IcloudSyncHealthEvidenceSnapshot`, and `ProviderGlobalSyncEvidenceSnapshot` records under `volume-pressure-evidence`, `provider-client-runtime-evidence`, `icloud-sync-health-evidence`, and `provider-global-sync-evidence`; iCloud plans combine their three-stream cohort, while third-party plans retain the provider-global stream for restart-safe blocker duration. | Missing, incomplete, malformed, or more-than-five-minute-skewed iCloud cohort observations remain blocked; third-party provider-global history can only extend a matching diagnostic clock and never grants copy, attestation, or eviction authority. | | P1 | Hourly product-development/review loop is not yet live in this repository environment. | The repository-local `.github/workflows/hourly-product-loop.yml` is intentionally `workflow_dispatch`-only because its direct contextual-orchestrator HTTP call is advisory and not a pinned OpenCode worker. The trusted central [`disksage-hourly-review-repair.yml`](https://github.com/ContextualWisdomLab/.github/blob/main/.github/workflows/disksage-hourly-review-repair.yml) runs at `37 * * * *` and dispatches the pinned scheduler `a3fdaa1aacaba9443a18573f3c309fe1841fc2f0`, which performs the OpenCode OIDC exchange. The local workflow still uploads a seven-day path-free receipt when manually configured; no external endpoint or deployment receipt is available here. | Verify one central scheduler receipt and one local manual advisory receipt; preserve read-only permissions, exact-head binding, and no provider-secret import or mutation. | -| P1 | Open PR queue prevents a clean protected release line. | At this loop capture PR #213 is exact head `6f424af` on `feat/provider-sync-dynamic-goals`; its required checks reset after the provider-dump pipe repair and the prior review decision remains stale `CHANGES_REQUESTED`. The orphan cleanup follow-up is PR #245, initially implemented at `3d2406c` and subsequently extended with provider-sync and cleanup-refresh safety fixes. Both remain protected and unmerged pending exact-head review. | Process one PR at a time: current-head review → fix → required checks → fresh approval → normal protected merge; never bypass or self-approve. | +| P1 | Open PR queue prevents a clean protected release line. | The current exact-head inventory above still has protected review/quorum gaps; PR #189 and #247 have checks running, while PR #238 is green but has no qualifying approval. | Process one PR at a time: current-head review → fix → required checks → fresh approval → normal protected merge; never bypass or self-approve. | | P1 | Current UI coverage is contract-heavy rather than runtime E2E for native File Provider states. | The UI now displays `로컬 최신본·업로드 미확인` and maps blockers without backend detail; provider operations are not safely reproducible on this full disk. Rust fixtures now cover `local-current + is_uploaded=false`, provider timeout, timeliness transitions, and receipt/evidence invalidation; native runtime E2E remains unavailable while the provider is unhealthy. | Keep the fixture-backed state machine green and add a bounded native E2E receipt only after a quiet provider observation is authoritative. | +| P1 | Preview headroom could disagree with native mutation headroom on cross-volume layouts. | The mutation boundary already probes the destination staging ancestor, while the old preview/UI gate used the source-volume snapshot. | The planner now probes the destination for every visible unblocked candidate and the native UI follows the resulting insufficient/unverified notices; provider-API upload remains separate. | | P1 | Ontology/catalog integrations are export boundaries, not deployed services. | Naruon/semantic catalog and Zotero local API docs/contracts exist; no Noema/contextual-orchestrator runtime dependency is required. | Keep integrations optional and path-free; add live service tests only when a concrete consumer and secret boundary exist. | | P2 | 100% documentation/docstring and edge-case coverage is not yet evidenced. | Existing checks cover core Rust/TS behavior, not a repository-wide percentage claim. | Publish measured coverage per language and close high-risk edge paths before claiming 100%. | | P2 | Figma design source is not part of the current change. | No visual redesign or Figma artifact was introduced in this baseline. | If a product UI redesign is approved, record the Figma File ID in a new ADR before implementation. | @@ -165,6 +192,41 @@ authoritative, and no merge is claimed from queued or stale status. At each scheduled or operator loop, update this file only with new dated evidence: current head, open-PR/check state, provider receipt state, disk headroom, and the smallest acceptance proof completed. Do not convert an incomplete provider probe, filename date, model answer, or GitHub review comment into a transfer or deletion authority. +## 2026-08-24 18:51 +0900 exact-head and host delta + +- PR #225 is at `3715a5ada760072d3675026fe7f264b4ee47964f`; its Windows failure was reproduced from + the hosted log: two tests supplied POSIX `/home/u`, which the fail-closed Windows resolver correctly + rejects. The existing `platform_home()` fixture now supplies a Windows absolute path; pinned Rust + organize tests pass 21/21, with `rustfmt --check` and `git diff --check` passing. +- PR #227 is at `753352a1d0cd7e297bb656d5edf9339235a628a3`; its Ubuntu failure was caused by the + runner missing `lsof`, which the fail-closed active-use probe requires. The existing system-dependency + step now installs `lsof`; the local full Rust suite passes 735/735 with one ignored live-provider test. +- A fresh read-only host observation measured 16 GiB available on `/` (926 GiB total, 43% used), while + `brctl status` still reports iCloud `needs-sync` and repeated `pending-scan` entries roughly 1.37 hours + old. Finder, `fileproviderd`, and `bird` are running; no DiskSage process was present. This supports a + provider reconciliation/indexing stall, not local disk exhaustion or a proven DiskSage lock. +- The Finder `real_datasets` copy remains unmaterialized in the bounded provider evidence. No Finder + cancellation, provider restart, CloudDocs write, cloud mutation, source mutation, attestation, or + eviction was performed; `provider_sync_attested=false`, `local_eviction_authorized=false`, and + `mutation_performed=false` remain the only safe state until fresh per-item evidence exists. + +## 2026-08-21 23:30 +0900 live iCloud Finder-preparation receipt + +- The exact-head `disksage-icloud-sync-health` binary completed a bounded, read-only iCloud + observation. The report was `evidence_complete=true`, native status `needs-sync-up` plus + `needs-sync-down`, and File Provider activity schema 3 with one no-progress fetch, active + upload/download markers at `953100`/`988500` millionths, and `pending_indexable_count=17547`. + The upload queue also retained six `blocked_on_sync_up` items; 18 filename exclusions and two + root exclusions were observed. New-copy admission is `blocked`; `mutation_performed=false`. +- `fileproviderctl` also showed active iCloud materialization/fetch jobs and a roughly 14,965-entry + reconciliation backlog. This is consistent with the Finder `real_datasets` “복사 준비 중” + symptom, but it is not a per-item copy receipt. DiskSage keeps copy, attestation, and source + eviction fail-closed until a fresh complete quiet observation and per-item provider evidence + exist. No Finder/provider daemon, CloudDocs database, cloud object, or source file was changed. +- Current exact PR heads are UX #246 `fc9f4a4c465fc5ef355f7fbf552ff4295cf4f609` and provider + #247 `45214018dff43c6ba7c71253bc50e8c0eab0e1bd`; their hosted checks remain pending, while + local Rust/UX validation is green. The live observation does not authorize a protected merge. + ## 2026-08-21 lineage graph update - Source head `677042467b3398866757f39b9475bd0b267abc75` now exports path-free ontology relations for @@ -655,6 +717,57 @@ At each scheduled or operator loop, update this file only with new dated evidenc complete; DiskSage keeps copy, attestation, and eviction fail-closed. This separates the active provider-index backlog from the earlier low-space pressure incident. +## 2026-08-21 bounded-planning and evidence-retention follow-up + +- Exact duplicate detection now runs before the candidate presentation limit, so a duplicate pair + split by `limit` still marks the visible candidate for human canonical selection and remains in + the path-free cluster summary. A focused Rust regression test covers this boundary. +- Provider evidence retention protects the just-written record when its timestamp is older than + the existing history, preventing clock regression from deleting fresh proof. The retention + integration test covers the bounded 128-record history. Local implementation commit `c5aa3a1` + is not yet published because the repository ruleset currently rejects direct branch updates. + +## 2026-08-21 current exact-head iCloud/indexing and PR audit + +- Exact-head local commit `c5edabd` adds the path-free iCloud File Provider + `pending_indexable_count` field, emits `icloud-file-provider-indexing-pending`, includes it in + Naruon readiness and the stable UI blocker fingerprint, and records the change in ADR-0001, + this baseline, and `CHANGELOG.md`. The pinned Rust 1.97.1 parser test passed; `npm run check` + reported 0 errors/0 warnings and the CloudArchive contract suite passed 3/3. +- The rebuilt `disksage-icloud-sync-health` observed at `2026-08-21 21:54:33 +0900` returned + `schema_version=5`, complete evidence, native `idle`/`has-synced-down`, File Provider activity + schema 3 with pending indexable `12474`, active upload/download `1/1`, and filename/root + exclusions `18/2`. Admission remains blocked by upload-in-flight, both exclusion blockers, + indexing-pending, and transfer-active; `mutation_performed=false`. +- A normal push of `c5edabd` was rejected by ruleset `GH013` because branch changes must go through + a pull request and the central required workflows are unsatisfied. No bypass, force-push, admin + merge, or self-approval was used. Remote PR #213 therefore remains at `108bba0`; the local + follow-up is explicitly unprotected until a normal PR path becomes available. + +## 2026-08-21 iCloud indexing backlog follow-up + +- A repeated read-only iCloud File Provider observation remained unchanged for 21 seconds with + `pending-indexable-count=12474`, upload progress `0/5038` at `0.0000`, 18 filename exclusions, + and 2 root exclusions. DiskSage now exports the aggregate indexing backlog, blocks new-copy + admission with `icloud-file-provider-indexing-pending`, and surfaces the count in the Finder + “복사 준비 중” warning. No provider or source mutation was performed. + +## 2026-08-21 current iCloud indexing and transfer receipt + +- A fresh bounded read-only `fileproviderctl` observation completed at `2026-08-21 22:22:53 +0900`. + It reported `needs-indexing=no`, pending indexable `13737`, a `12449`-entry reconciliation + backlog, active upload/download markers, one no-progress fetch, and filename/root exclusions + `18/2`. The bounded dump was truncated; DiskSage persisted only path-free aggregate evidence and + set no-progress, indexing-pending, transfer, and exclusion blockers. `mutation_performed=false`; + Finder preparation is not a copy receipt. + +## 2026-08-21 provider disk-full marker boundary + +- Provider-global sync parsing now treats only the exact numeric markers `errno 28`, + `odresult_errno 28`, and `OSStatus -34` as local-disk-full evidence; longer values such as + `errno 280` are retained as generic provider errors. The focused boundary regression passes, + and no provider, source, or cloud state was mutated. + ## 2026-08-22 readiness verifier integration boundary - The Naruon readiness verifier's source comment is now valid both as a standalone binary and when @@ -679,3 +792,760 @@ At each scheduled or operator loop, update this file only with new dated evidenc pipe leak that could starve the independent `ps` probe and report a false active-use timeout. The focused Rust test passed 3/3. The same patch is present on stacked PR heads `a0fa7bc` (#247) and `741ab30` (#246); hosted checks are rerunning and protected merge/review is still pending. + +## 2026-08-22 File Provider disk-import detection + +- A fresh bounded `fileproviderctl` observation showed the iCloud domain with Finder enumerators, + `disk import: yes`, active upload progress of `5,202,024,494 / 5,462,125,152` bytes, and + `pending-indexable-count=30,960`. These aggregate markers explain why Finder can remain in + “복사 준비 중”, but they do not bind the operation to `real_datasets` or prove a per-item cloud + copy. DiskSage now records the redacted `icloud-file-provider-disk-import-active` notice, + projects it into the new-copy admission blockers and Naruon readiness export, and shows it next + to the existing fixed Finder-cancel action. Copy, attestation, and source eviction remain + fail-closed; no provider process, source, CloudDocs database, or cloud object was mutated. + +## 2026-08-22 04:05 +0900 unchanged iCloud preparation queue + +- A second read-only host observation found the same bounded aggregate values after the earlier + disk-import evidence: `pending-indexable-count=31,024`, upload + `5,202,024,494/5,462,125,152` (95.24%), download `0/828`, and `disk import: yes`. + `brctl` still reported `needs-sync-up`/`needs-sync-down`, with last sync at + `2026-08-21 20:20:10.166 +0900`; many `pending-scan` entries were three or more hours old. +- This strengthens the product diagnosis of a stalled File Provider preparation queue but does + not bind the state to a particular Finder item or prove a cloud copy. DiskSage performed no + cancellation, daemon restart, provider-database write, materialization, cloud mutation, or + source mutation. The runtime Goal remains `provider-sync-incomplete`; copy, attestation, and + source eviction remain blocked until a fresh complete quiet observation and independent + per-item evidence exist. + +## 2026-08-22 persisted stall-duration wiring + +- The current bounded probe at `2026-08-21 20:21:04 +0000` still reports two no-progress fetches, + `pending-indexable-count=31882`, unchanged aggregate upload/download counters, and active disk + import. The iCloud health command already derives `admission_blocked_since_ms` from the + integrity-checked evidence journal, but the frontend previously ignored that field and restarted + its 15-minute clock after a UI/system restart. +- DiskSage now carries the field through the TypeScript report contract and uses it as the UI stall + clock origin, with a current-observation fallback only when persistence is unavailable. This + makes the screenshot's long-running “복사 준비 중” state remain visible as a stall after restart; + it does not cancel Finder, write provider state, or authorize copy, attestation, or eviction. + +## 2026-08-22 06:05 +0900 repeated Finder preparation stall + +- A new bounded, read-only observation still found four `fetchContentsForItemWithID` requests with + no progress, `pending-indexable-count=31882`, active disk import, unchanged aggregate upload + (`5205160706/5465661912`) and download (`10647837/11116116`) counters, and `brctl` flags + `needs-sync-up|needs-sync-down`. Finder had remained alive for roughly 18 hours and the data + volume had only about 4.9 GiB available. +- The observation confirms provider-level preparation debt but remains aggregate evidence: it does + not identify the seven Finder items in `real_datasets` or prove any cloud copy. DiskSage keeps + the runtime Goal `provider-sync-incomplete`, copy/attestation/source eviction fail-closed, and + exposes only the explicit bounded Finder-cancel action; no Finder/provider process, CloudDocs + database, source, or cloud object was mutated. + +## 2026-08-24 current File Provider reconciliation backlog + +- A fresh bounded, read-only `fileproviderctl` observation after the system restart reported + `needs-indexing=no` but `pending-indexable-count=32377`, a `28123`-entry reconciliation queue, + upload progress `6229217391/6540678102` (95.24%), `scheduling state: running`, `disk import: yes`, + and `stream reset: yes`. `brctl status` still reported `needs-sync-up|needs-sync-down` with the + last sync at `2026-08-21 20:20:10.166 +0900`; repeated pending scans were roughly 54 hours old. +- These are provider-global markers and do not bind to the seven Finder items in `real_datasets` or + prove a per-item cloud write. DiskSage therefore keeps Goal `provider-sync-incomplete`, copy, + attestation, and source eviction fail-closed, and leaves only the explicit bounded Finder-cancel + action available. No Finder/provider process, CloudDocs database, source, or cloud object was + mutated by this observation. + +## 2026-08-24 11:13 +0900 provider-specific Finder stall follow-up + +- A fresh read-only Google Drive File Provider dump was approximately 4.99 MiB, confirming that + the provider-wide probe must retain its 32 MiB bounded cap; the product branch already carries + that cap and parses `temporarily disconnected`, `NSFileProviderErrorDomain -1004`, active + transfer, reconciliation, and item-not-found markers without retaining paths. +- The live log recorded Google Drive root materialization failures with File Provider error + `-1004` (server/device connection unavailable) while iCloud continued redacted item ingestion. + This makes the provider identity part of the user diagnosis: a Finder “preparing to copy” dialog + is not sufficient evidence of a cloud write and cannot be mapped to `real_datasets` without an + item-level receipt. +- Current exact heads are PR #247 `3e43e0d4d3aa15a7f25161f4107bf3f2c29d261f` and PR #156 + `25b3e42be7e0e22cafca878ef25383959dd773d6`; both have hosted checks still running/queued and + neither has a qualifying protected approval. No process, provider database, source file, or + cloud object was mutated. The runtime Goal remains `provider-sync-incomplete` and all copy, + attestation, and source-eviction gates remain fail-closed. + +## 2026-08-24 11:34 +0900 worsening iCloud preparation queue + +- A subsequent bounded read-only iCloud observation increased `pending-indexable-count` to `39404` + and reconciliation to `35150`; upload remained `6229217391/6540678102` (95.24%) with scheduling + running, `disk import: yes`, and `stream reset: yes`. `brctl` still reports + `needs-sync-up|needs-sync-down`, with pending scans about 55 hours old. +- The aggregate queue is worsening rather than quieting. It remains incident evidence only: it + does not identify the seven `real_datasets` items or prove a cloud write. DiskSage keeps the + runtime Goal `provider-sync-incomplete`, exposes only the bounded Finder-cancel action, and + keeps copy, attestation, and source eviction fail-closed. No provider process, CloudDocs + database, source file, or cloud object was mutated. + +## 2026-08-24 provider-stall duration persistence + +- The provider-global admission report now carries a backend observation timestamp and an optional + `admission_blocked_since_ms` value. OneDrive/Google Drive probes persist only bounded, path-free + aggregate snapshots with create-only `0400` records, `0700` directory permissions, SHA-256 + integrity, and 128-record retention; raw File Provider dumps and user paths are not retained. +- CloudArchive consumes the persisted onset for the same provider/blocker fingerprint. Therefore + a Finder “복사 준비 중” dialog that survives a restart is shown as a continuing stall instead of + a newly observed five-minute window. Invalid or tampered history falls back to the current + observation and remains fail-closed; the feature never cancels Finder or authorizes cloud copy, + attestation, or source eviction. + +## 2026-08-24 12:16 +0900 exact-head and review repair correction + +- PR #247's latest source fix is `7e82b0c` after the provider-global restart-duration + implementation. It scopes the persisted stall walk to the observed provider and preserves the + onset already accumulated when an older record cannot be read or parsed. The earlier + `7fa3f7d...`, `3db3c33...`, `3e43e0d...`, and `2ee31ea...` rows are predecessor evidence; their + checks and reviews are stale. The PR remains ready for review with no qualifying approval, and + the live PR head/checks must be re-fetched after this documentation publication. +- Parent PR #213 is ready for review at exact head `0584bcc600e037d564a4ff254b6e8570361d9218`; + its hosted coverage/security/release checks are green, but protected review quorum is absent. +- Local proof for the source fix is Rust provider-global 20/20, provider/readiness + integration tests 6/6, frontend Vitest 32 files/133 tests, and `svelte-check` 0 errors/0 + warnings. These checks do not authorize a protected merge or any Finder/provider/source/cloud + mutation. + +## 2026-08-24 12:26 +0900 repeated Finder preparation stall + +- Finder PID 1422 has been alive for about 1 hour 42 minutes; `fileproviderd` remains active at + about 22% CPU while `bird` is present. The `real_datasets` destination remains 14 entries, + 512 bytes, and mtime `2026-08-20 03:28:07`, so no destination byte-copy progress was observed. +- The root volume has about 91 GiB available. `brctl status` still reports iCloud + `needs-sync-up|needs-sync-down` with the last sync at `2026-08-21 20:20:10.166 +0900` and + repeated pending scans. This is a provider preflight/indexing stall, not local capacity pressure + and not proof of a cloud write for the seven Finder items shown in the dialog. +- DiskSage performed read-only inspection only. It did not cancel Finder, restart/kill provider + daemons, modify CloudDocs/provider state, or mutate source/cloud data; Goal + `provider-sync-incomplete` and all copy/attestation/eviction gates remain fail-closed. + +## 2026-08-24 12:35 +0900 Strix provider-prefix failure in the open-PR queue + +- DiskSage PR #249 exact head `44390608d30417477f6a66601b18a53ca87b0a9c` has a failed Strix + check. The run reached its configured fallback `openai-direct/gpt-5.6-luna`, but LiteLLM + rejected that hyphenated provider prefix (`LLM Provider NOT provided`) before producing a + vulnerability report; the required check correctly failed closed rather than treating zero + findings as authoritative evidence. +- The root repair is in the central `.github` PR #1263 exact head + `3669bceba9679883d10ffa859eea87bf4705dfd3`: normalize `openai-direct/` to + `openai_direct/`, dispatch LiteLLM as `openai/gpt-5.6-luna`, and switch the credential/API-base + boundary for cross-provider fallbacks. Its current head has no unresolved review thread, but + its protected review decision remains stale `CHANGES_REQUESTED` while the Strix check runs. +- This is CI-provider infrastructure evidence, not a DiskSage data or Finder mutation. No local, + provider, source, or cloud data was changed by the diagnosis. + +## 2026-08-24 12:39 +0900 executed DiskSage iCloud admission probe + +- The exact product head's `disksage-icloud-sync-health` binary completed a read-only local + CloudDocs/WAL snapshot with `evidence_complete=true`, `mutation_performed=false`, + `provider_sync_attested=false`, and `local_eviction_authorized=false`. The snapshot is + supplementary global evidence; it does not claim a per-item cloud write for `real_datasets`. +- iCloud reported `needs-sync-up|needs-sync-down`, 343 uploads blocked on sync-up, one active + upload at 95.24%, one active download, and `pending_indexable_count=58183`. The admission state + is `blocked` with transfer-active, disk-import, indexing-pending, root/filename exclusion, and + native sync-up/down blockers. This directly explains why Finder remains in “복사 준비 중”. +- The probe read SQLite through a copy-on-write snapshot including WAL files, redacted paths, did + not write CloudDocs/provider state, and did not cancel Finder or mutate any source/cloud object. + +## 2026-08-24 12:15 +0900 current Finder preparation stall observation + +- Finder has remained alive since `10:43:49 +0900`, while the visible `real_datasets` operation + remains in “복사 준비 중”. The local destination directory's mtime and size stayed unchanged + at `2026-08-20 03:28:07` and 512 bytes across bounded checks from `12:14:02` through + `12:14:12`; no new destination or temporary file appeared after the incident start window. +- The root volume had 86 GiB available, so local capacity is not the current blocker. A bounded + Finder sample stayed in DesktopServices/FileProvider URL-property and child-synchronization + work rather than a byte-copy path. This is evidence of preflight/provider waiting, not a copy + receipt and not proof that any of the seven displayed items reached a cloud object. +- `brctl` still reports `needs-sync-up|needs-sync-down` with the last sync at + `2026-08-21 20:20:10.166 +0900`. OneDrive's latest diagnostic reported zero bytes/files + queued and no download/upload failures; this does not prove Finder's source selection, so the + product keeps the provider identity and item-level receipt separate. +- DiskSage performed only read-only inspection. It did not cancel Finder, restart or kill + `bird`/`fileproviderd`, write a CloudDocs/provider database, or mutate a source or cloud object. + Goal `provider-sync-incomplete`, copy/attestation/eviction gates, and the explicit bounded + Finder-cancel action remain unchanged. + +## 2026-08-24 12:51 +0900 impossible stall-onset values rejected at the UI boundary + +CloudArchive now accepts a persisted blocker onset only when it is a safe integer in the observed +time range. Negative, future, non-finite, or otherwise impossible values fall back to the current +backend observation instead of producing a negative duration or hiding the 15-minute Finder-stall +warning. The focused Vitest contract passes four cases and `svelte-check` reports zero diagnostics; +this diagnostic guard grants no copy, attestation, cloud-write, or source-eviction authority. + +## 2026-08-24 12:57 +0900 frontend coverage evidence + +The exact product worktree ran all 32 frontend test files (134 tests) successfully. V8 reports +100% statements (211/211), branches (70/70), functions (83/83), and lines (173/173) for the +instrumented frontend surface, including the impossible stall-onset contract. This is frontend +test evidence only; it does not imply repository-wide 100% coverage or provider/cloud authority. + +## 2026-08-24 12:58 +0900 iCloud preparation queue remains blocked + +The latest exact-head read-only probe still reports `new_copy_admission_state=blocked` and +`mutation_performed=false`. The native summary remains `needs-sync-up|needs-sync-down`; 343 upload +items are blocked on sync-up, one upload and one download are active, and FileProvider's pending +indexable count increased to 64,969 (from 58,183 at 12:39). Disk import, transfer activity, and +the 28 filename/2 root exclusions remain present. The Finder target is unchanged at 14 entries, +512 bytes, mtime `2026-08-20 03:28:07 +0900`, with about 101GiB free on `/`. DiskSage therefore +continues to block new copy, attestation, and source eviction; no Finder/provider/source/cloud +mutation was performed. + +## 2026-08-24 13:04 +0900 iCloud indexing backlog increased + +A subsequent exact-head read-only probe observed the same native `needs-sync-up|needs-sync-down` +state, 343 uploads blocked on sync-up, one active upload at 95.24%, one active download, and +`new_copy_admission_state=blocked`. FileProvider pending indexable items increased from 64,969 to +67,017 while disk import, transfer activity, and the 28 filename/2 root exclusions remained +present. Aggregate evidence still has `provider_sync_attested=false`, `local_eviction_authorized=false`, +and `mutation_performed=false`; no Finder/provider/source/cloud mutation was performed. + +## 2026-08-24 13:12 +0900 iCloud backlog growth and process attribution + +The next exact-head read-only probe observed `pending_indexable_count=74,946` (up from 67,017), +the same native `needs-sync-up|needs-sync-down` state, 343 uploads blocked on sync-up, one active +upload at 95.24%, one active download, and `new_copy_admission_state=blocked`. Finder's +`real_datasets` destination remained 512 bytes with mtime `2026-08-20 03:28:07 +0900`; `/` had +about 99GiB available. Bounded process inspection saw `fileproviderd` at 72–129% CPU but no +DiskSage process, CloudDocs database, or source path open in it. This is provider-side backlog +evidence, not proof of a DiskSage database lock; provider attestation, eviction authorization, and +all mutations remain disabled. + +## 2026-08-24 13:12 +0900 live protected PR inventory correction + +The earlier 12:51 table is historical. The live GitHub inventory now has PR #247 on `main` at +`584d0ede1a6fef75b7bfc2191aa3ea47e59b2a66` (open, non-draft, checks pending, review required), PR +#246 at `476678c150ded97b400d62566292adfff56a84c2` (open, non-draft, all required checks pass but +no approvals), and the remaining open queue includes #249, #244, #238, #236, #234, #232, #231, +#230, #228, #227, #225, #223, #222, #220, #218, #217, #216, #215, #214, #212, #209, #208, +#207, #206, #205, #204, #203, #202, #200, #199, #198, #197, #195, #193, #192, #190, #189, +#188, #187, #186, #182, #181, #179, #174, #156, #150, and #149. No protected merge is inferred +from `CLEAN`, green predecessor checks, or bot comments; the live ruleset still requires two +independent approvals, last-push approval, resolved threads, and normal merge/squash. + +## 2026-08-24 13:53 +0900 live Finder/provider follow-up + +- A bounded local recheck at `13:48:21 +0900` found about 96 GiB available on `/`. Finder PID 1422, + `fileproviderd` PID 1450, and `bird` PID 1462 had all remained alive for roughly 3 hours; the + `real_datasets` target was still 512 bytes with mtime `2026-08-20 03:28:07 +0900`. No target + handle appeared in the bounded process handle sample; File Provider held only its Mobile Documents + root and `bird` held CloudDocs session database shared-memory files. +- The latest complete DiskSage iCloud health receipt available for this loop (`13:12`) reported + `new_copy_admission_state=blocked`, 343 uploads blocked on sync-up, one active upload at 95.24%, + one active download, and 74,946 pending indexable items. The evidence is aggregate and does not + identify the seven Finder items or prove a cloud write; `provider_sync_attested=false`, + `local_eviction_authorized=false`, and `mutation_performed=false` remain explicit. +- This confirms a File Provider reconciliation/indexing backlog rather than local disk exhaustion or + a DiskSage lock. The UI keeps the explicit bounded Finder-cancel action as the only operator + mutation, while new copy, attestation, and source eviction remain fail-closed. No Finder/provider + process, CloudDocs database, source file, or cloud object was changed. + +## 2026-08-24 14:11 +0900 live iCloud probe confirms worsening backlog + +- The exact-head `disksage-icloud-sync-health` binary completed another read-only CloudDocs/WAL + snapshot with `evidence_complete=true`, `new_copy_admission_state=blocked`, + `provider_sync_attested=false`, `local_eviction_authorized=false`, and + `mutation_performed=false`. +- The aggregate provider state still has 343 uploads blocked on sync-up and one active upload at + 95.24%; File Provider pending indexable items increased from 74,946 to 103,013, with one active + download and disk-import/transfer activity still present. This is provider reconciliation + evidence, not proof that any Finder item reached the cloud. +- The target remained 14 entries, 512 bytes, and mtime `2026-08-20 03:28:07 +0900`; `/` retained + about 94 GiB available. DiskSage therefore continues to block new copy, attestation, and source + eviction. The only available operator mutation remains the explicit Finder-cancel action. + +## 2026-08-24 14:27 +0900 exact-head PR audit + +The following live heads were re-queried before this documentation update; predecessor reviews and +checks are not reused: + +- DiskSage #189 is open/non-draft at `288904ff8b81d769847869f7b434065d7613b1d7` after absorbing + current `main`; required checks are queued/in progress, with no unresolved review threads. +- DiskSage #212 is open/non-draft at `779afa48cc8bc534a6e5cc910714324d85f7358b`; its OAuth help + contract and dead-entrypoint fixes are pushed, required checks are queued/in progress, and all + current review threads are resolved. +- DiskSage #238 is merged at `d44b23bdf4108bf6b6f6378f7e0ac305187deec6`; it is no longer an open + merge candidate. +- DiskSage #247 is draft/open at `c9ac3b2041cc6736fb60fde773c3c6fbd21fcdc2`; the latest iCloud + evidence/ADR update is pushed, required checks are queued, and no review thread is unresolved. +- DiskSage #249 remains draft/open at `44390608d30417477f6a66601b18a53ca87b0a9c`; its previous + Strix failure remains a provider-gate issue and is not treated as a product merge approval. +- Central `.github` #1263 is open/non-draft at `14cd0e8438b6d670a0f036d1e47f35bd4c3f97a7`; the + cross-repository documentation reference is qualified, but protected checks/reviews are pending. + No merge is inferred from queued checks or bot comments. + +## 2026-08-24 14:31 +0900 live iCloud probe confirms copy-preparation stall + +- A fresh exact-head `disksage-icloud-sync-health` read-only probe again reported + `evidence_complete=true`, `new_copy_admission_state=blocked`, `provider_sync_attested=false`, + `local_eviction_authorized=false`, and `mutation_performed=false`. +- The aggregate state remained 343 uploads blocked on sync-up with one active upload at 95.24%, + one active download, and File Provider pending indexable items increased to 110,652. Native + status still reported `client_state=needs-sync` with sync-up/down pending; filename/root + exclusions and disk-import/transfer activity remained present. +- The root volume had about 83 GiB available (13% used), and a bounded `lsof` sample found no + handle on `real_datasets`. The Finder “preparing to copy” dialog is therefore a provider + reconciliation/indexing stall, not local disk exhaustion or evidence of a completed cloud + copy. DiskSage keeps copy, attestation, and source eviction fail-closed; only the explicit + bounded Finder-cancel action is available to the operator. + +## 2026-08-24 14:38 +0900 exact-head queue refresh + +- DiskSage #189 advanced to `8809e6cdc8da14915a9e0219481f75a1faebfdb9` after absorbing current + `main`; it is open/non-draft with required checks pending and no unresolved review threads. +- DiskSage #212 remains open/non-draft at `779afa48cc8bc534a6e5cc910714324d85f7358b`; checks are + pending and no qualifying approval is present. +- DiskSage #247 is draft/open at `16f511f8af8320ccd885c06c1de60ad00dfbbf12`; the current iCloud + evidence update is pushed, checks are re-running, and no review thread is unresolved. +- DiskSage #249 remains draft/open at `44390608d30417477f6a66601b18a53ca87b0a9c`; its prior + provider-gated Strix result is not merge evidence. Central `.github` #1263 has advanced to + `7011fee275eaa257ce491efb4812dd3e98ed649e` and remains blocked with changes requested. + No merge is inferred from queued checks or bot comments. + +## 2026-08-24 14:40 +0900 exact-head queue refresh + +- DiskSage #247 advanced to `618acff21b78ba93a40a7c0d48b99961ba79f4dc` with an additional public + plan regression for folded mail headers; the preceding destination-headroom test and iCloud + evidence remain in the exact ancestry. Checks are re-running and the draft remains blocked. +- The other live references are unchanged: #189 `8809e6cdc8da14915a9e0219481f75a1faebfdb9`, + #212 `779afa48cc8bc534a6e5cc910714324d85f7358b`, #249 + `44390608d30417477f6a66601b18a53ca87b0a9c`, and central `.github` #1263 + `7011fee275eaa257ce491efb4812dd3e98ed649e`. No protected merge is inferred from pending + checks, historical reviews, or bot comments. + +## 2026-08-24 14:45 +0900 exact-head product queue refresh + +- DiskSage #247 is now exact head `f23539684651e9280962271759841f9d0fdd377a`, a draft/open + provider-indexing follow-up that also contains the folded-header and destination-headroom + regressions plus the standards-safe UI label convergence. Local frontend checks passed on this + tree; hosted checks are pending and no review thread is unresolved. +- The next product gaps are visible in the live queue: #246 `9cf11c0194aece52a2769b9d10b8f20b7d2658e5` + (accessible Storybook UX contracts) and #244 `b9941295ac354bb63cf911a064a1f4df1f8eb60b` + (Rust 1.97.1 baseline) are draft/open; #189 remains `8809e6c`, and #212 remains `779afa4`. + Central `.github` #1263 remains `7011fee` with changes requested. Protected merge is not inferred + from draft status, queued checks, or historical approvals. + +## 2026-08-24 14:47 +0900 exact-head regression repair + +- A local exact-head run initially exposed `source-snapshot-stale` in #247's destination-headroom + test because its fixture used sentinel timestamps (`created_ms=1`, `modified_ms=1`) for a file + that the public planner revalidates. The test—not the destination-authority implementation—was + stale. Head `e9a3fd8` now binds the fixture bytes/mtime to the materialized source and uses the + observed clock. +- Pinned Rust 1.97.1 execution now passes both runtime regressions: destination ancestor headroom + authority and folded mail-header planning (2 passed). This preserves the real source freshness + gate while testing the intended symlinked-staging safety behavior. + +## 2026-08-24 14:55 +0900 live iCloud recheck + +- The bounded `disksage-icloud-sync-health` probe completed with `evidence_complete=true` and + `new_copy_admission_state=blocked`: 343 uploads remain blocked on sync-up, one upload is active + at 95.24%, one download is active, and File Provider pending indexable items reached 121,859. +- The root volume has 66 GiB available; `real_datasets` still has 14 entries and 512 bytes with + its 2026-08-20 mtime, and the bounded `lsof` sample has no handle on that directory. This + confirms provider reconciliation/indexing stall evidence rather than disk exhaustion or a + Finder copy receipt. Copy, per-item attestation, and source eviction remain fail-closed. + +## 2026-08-24 15:34 +0900 iCloud queue continues to grow + +- The next bounded read-only receipt still reported `evidence_complete=true` and + `new_copy_admission_state=blocked`: 343 uploads remained blocked on sync-up, one upload stayed + active at 95.24%, and one download stayed active. File Provider pending indexable items grew + from 121,859 to 128,917; disk import, transfer activity, and the 28 filename/2 root exclusions + remained present. Native status remained `client_state=needs-sync` with sync-up pending. +- This is provider-global reconciliation evidence, not a per-item receipt for the seven + `real_datasets` entries and not proof of a cloud write. DiskSage keeps Goal + `provider-sync-incomplete`, copy/attestation/source eviction fail-closed, and performed no + Finder, provider, source, or cloud mutation. + +## 2026-08-24 15:46 +0900 iCloud indexing backlog continues to rise + +- A fresh bounded read-only probe reported `evidence_complete=true` and + `new_copy_admission_state=blocked`: 343 uploads remain blocked on sync-up, one upload remains + active at 95.24%, and one download remains active. File Provider pending indexable items reached + 130,571, with disk import/transfer activity and the 28 filename/2 root exclusions still present; + native status remains `client_state=needs-sync` with sync-up pending. +- This is aggregate provider reconciliation evidence that explains the Finder “preparing to copy” + symptom but does not identify the seven items or prove remote upload. DiskSage keeps + `provider-sync-incomplete`, copy/attestation/source eviction fail-closed, and performs no + Finder, provider, source, or cloud mutation. + +## 2026-08-24 15:42 +0900 Strix provider evidence separated from source readiness + +- The exact-head central `.github` PR #1263 Strix artifact (`32693700056`) recorded NVIDIA NIM + HTTP 429 rate limiting on the primary and retries, followed by a direct OpenAI fallback HTTP + 404 for `openai-direct/gpt-5.6-luna`. The gate correctly retained the provider-failure signal + and did not promote the fallback's zero-finding report to a successful security result. +- This is external model-provider availability evidence, not proof of a DiskSage source defect or + a cloud/data mutation. The central repair remains subject to a fresh authoritative same-head + Strix run and protected approvals; DiskSage's local iCloud admission and eviction gates are + unaffected and remain fail-closed. + +## 2026-08-24 16:03 +0900 exact-head review queue refresh + +- DiskSage #227 is ready for review at `fd841e9e6b76dc2d47d62d2fddabe53eecf544b2`; its current + review threads are resolved, macOS bound-root passed, and the remaining hosted checks plus the + two independent protected approvals are still required. +- DiskSage #247 is ready for review at `59057c08eb5017ac57b640419a0c7e4779f443d7`; the iCloud + indexing evidence and customer-facing admission messages are in the exact ancestry. Checks are + queued and no protected approval is present. +- DiskSage #246 is ready for review at `308be49b56d1c38fbe9a5c00ab46ac2b3e51df73`; frontend + accessibility/Storybook checks were locally verified, while its hosted Strix result remains an + external provider gate that must be freshly revalidated. #244 remains open/non-draft with its + pinned Rust baseline checks queued. No merge is inferred from readiness or queued checks. + +## 2026-08-24 16:03 +0900 iCloud health recheck + +- The bounded probe remains fail-closed: `evidence_complete=true`, `new_copy_admission_state=blocked`, + 343 sync-up items blocked, one upload at 95.24%, one download active, and native + `client_state=needs-sync`/`needs-sync-up`. +- File Provider pending indexable items reached 131,214; disk import, transfer activity, and the + 28 filename/2 root exclusions remain. This is aggregate reconciliation evidence, not proof that + the seven Finder items were uploaded. No provider, Finder, source, or cloud mutation occurred. + +## 2026-08-24 16:08 +0900 review metadata and exact-head repair + +- DiskSage #244 keeps exact head `13caeb04333e50e57c8a51a11b64aeb131c080b2` with all review threads + resolved. Its PR description now matches the supported Dependabot configuration and records the + local pinned Rust documentation-test evidence without claiming a full hosted pass; checks and + protected approvals remain pending. +- DiskSage #227 advanced to `bf62ea0d74f077add672d0a193de154bde910b97` with a platform-specific + test-warning cleanup; its prior bound-root test passed 4/4 locally and hosted checks restarted. +- DiskSage #247 remains ready for review at `1535320c2b8b288376d9dcd35485a2af58374873`; its latest + iCloud evidence is exact-head and all copy/attestation/eviction mutations remain disabled. + +## 2026-08-24 16:12 +0900 CLI review repair + +- DiskSage #212 advanced to exact head `81c44e43205f21276c39f055f1878805f36e1072` and is ready for + review. Its mixed help-plus-invalid CLI test now preserves HOME so it exercises argument parsing, + while standalone help remains environment-independent; the targeted cloud-cli test passed 2/2. +- The provider OAuth environment contract remains intentionally in the default test matrix, and + its informational review thread was resolved without adding cloud credentials or side effects. + +## 2026-08-24 16:14 +0900 worktree-audit queue status + +- DiskSage #249 is now ready for review at `44390608d30417477f6a66601b18a53ca87b0a9c`; its + non-Strix checks passed in the last exact-head run, while Strix remains an external provider + availability failure requiring a fresh authoritative run. +- The PR is not merge-ready until that provider gate, current coverage, and protected review quorum + are satisfied. No worktree, source, provider, or cloud mutation was performed by this status + update. + +## 2026-08-24 16:14 +0900 Homebrew execution stack review status + +- DiskSage #205 (Intel Homebrew executable admission) is ready at `5c86668a6e503a174ff0b07151f67226b39547ff`; its hosted Test/Release/build checks are green but the stacked base and protected approvals remain. +- DiskSage #206 (content-bound Homebrew execution) is ready at `2e7b845b7610a871ec5981d964bcab5cb99df41d`; GitHub reports clean and hosted Test/Release/build checks are green. No approval bypass or merge was performed. + +## 2026-08-24 16:15 +0900 customer-facing UI queue status + +- DiskSage #203 (assistive table labels) is ready at `9d573f04145eb4168098623042484fdf73c2ab74`; + #202 (bounded scan/navigation failure feedback) is ready at + `1d005586b270ca1fcad445970cf44bf5e7268425`. Both have no unresolved review threads; protected + checks and approvals remain the merge gates. + +## 2026-08-24 16:18 +0900 Homebrew status UI verification + +- DiskSage #189 is ready at exact head `66d7aa767d416048a752c5c550e8d64e03213e0e`; the local + frontend regression slice passed 7/7 (`fmt` and `verdictBadge`), while coverage-source-tree and + protected approvals remain pending. + +## 2026-08-24 16:21 +0900 iCloud Finder copy remains provider-blocked + +- The bounded read-only health receipt still reports `evidence_complete=true` and + `new_copy_admission_state=blocked`: 343 uploads remain blocked on sync-up, one upload remains + active at 95.24%, and one download remains active. File Provider pending indexable items reached + 132,783; disk-import/transfer activity and the 28 filename/2 root exclusions remain, and native + status remains `client_state=needs-sync` with sync-up pending. +- This explains a multi-hour Finder “preparing to copy” symptom as provider-global reconciliation + pressure, but does not prove that DiskSage itself holds a Finder lock, identify the seven items, + or prove a cloud write. The product keeps `provider-sync-incomplete`, copy/attestation/source + eviction fail-closed and performs no Finder, provider, source, or cloud mutation. + +## 2026-08-24 16:32 +0900 exact-head review repairs + +- DiskSage #246 advanced to `1972614`; its coverage configuration now keeps the + `node_navigation` dead-code allowance without duplicating the attribute on + `preferred_scan_roots`. The pinned Rust 1.97.1 navigation slice passed 6/6 and the Devin thread + is resolved. +- DiskSage #227 advanced to `5ad1197`; the bound-root audit parameter now says `stable_root`, and + the intentional `duplicate_audit::bound_read_root` module contract was documented. The pinned + Rust 1.97.1 duplicate-audit slice passed 10/10 and both current informational threads are + resolved. Hosted checks and protected approvals still gate merge. + +## 2026-08-24 16:47 +0900 Git-worktree test artifact cleanup + +- DiskSage #249 advanced to exact head `db95c54` and is ready for review. Its three feature-gated + CLI integration tests now reuse deterministic private Cargo target directories and remove stale + output before nested builds, closing the repeated-test disk accumulation gap. The affected test + targets compile under pinned Rust 1.97.1; the help process slice passed 8/8 before this cleanup. +- The metadata-failure diagnostic remains a bounded generic fallback by design; it does not expose + paths or weaken the fail-closed private-report contract. Current hosted checks and protected + approvals remain required. + +## 2026-08-24 16:50 +0900 exact-head queue refresh + +- #203 is ready at `5f0bd51` with the current TopFiles accessibility contract; #244 is ready at + `13caeb0`; and #249 is ready at `db95c54` after the test-artifact cleanup. Their review threads + are resolved where applicable, but current hosted checks and the protected independent-approval + quorum remain merge gates. No merge or approval bypass was performed. + +## 2026-08-24 16:50 +0900 iCloud backlog remains the active customer blocker + +- The bounded probe now reports File Provider pending indexable items at 135,334, up from 132,783 + at 16:21; 343 uploads remain blocked on sync-up, one upload remains active at 95.24%, and one + download remains active. Native status remains `client_state=needs-sync` with sync-up pending and + `new_copy_admission_state=blocked`. +- The growing queue is consistent with Finder’s multi-hour “preparing to copy” state, but still + does not prove DiskSage holds a Finder lock or identify the seven items. No provider, source, + Finder, or cloud mutation was performed, and local eviction remains fail-closed. + +## 2026-08-24 16:55 +0900 concurrent test-target repair + +- #249 advanced to exact head `aa5c37d`; its shared test helper now uses process-scoped Cargo target + directories and prunes stale outputs without deleting another active run. The affected targets + compile under pinned Rust 1.97.1, and the current hosted checks have restarted for this head. + +## 2026-08-24 17:00 +0900 live brctl confirmation of the Finder stall + +- A fresh read-only `/usr/bin/brctl status` completed at 17:00. The iCloud container still reports + `client:needs-sync` and `sync:needs-sync-up|in-sync-down|prefer-sync-down|oob-sync-ack`; the + dump contains 1,740 `pending-scan` entries, 343 `pending-sync-up` entries, 1,807 scheduled + sync-up markers, and 5 upload errors. Individual queued uploads last ran roughly 60–66 hours + ago, including `CKErrorDomain:4` / “Saving asset failed” records. +- This is stronger provider-global evidence for the screenshot's multi-hour `real_datasets` + “복사 준비 중” state, but it still cannot identify the seven Finder items or prove a cloud + write. DiskSage performed no Finder/provider/source/cloud mutation; `provider-sync-incomplete`, + copy/attestation, and local-eviction gates remain fail-closed. The root volume currently has + about 36 GiB available, so the live blocker is provider reconciliation/error backlog rather + than a full root volume. +- At 17:02, the read-only process inventory showed Finder (PID 1422), `fileproviderd` (1450), and + `bird` (1462) all started at 10:43:49, about 6h18m earlier. This confirms a long-lived provider + session, not that DiskSage owns or has locked the Finder operation. +- Exact-head review evidence remains current: #249 is now `6b95c59` after centralizing the CLI's + reference validation in the library; #246 is `1972614`; #227 is `5ad1197`. Hosted checks and + protected independent approvals remain the only merge gates. + +## 2026-08-24 19:04 +0900 live iCloud queue still explains the copy-preparation stall + +- A fresh read-only `/usr/bin/brctl status` still reports the iCloud client as `needs-sync` with + `needs-sync-up|in-sync-down|prefer-sync-down|oob-sync-ack`. The bounded dump contains 1,740 + `pending-scan` entries and 343 `pending-sync-up` entries; the queue remains provider-global and + does not identify the seven Finder items. +- The host has about 12 GiB available on `/`, and the read-only process inventory contains Finder, + `fileproviderd`, and `bird` but no DiskSage process. This is consistent with provider + reconciliation/indexing pressure; it is not proof that DiskSage owns a Finder lock, nor proof + that the cloud write completed. Per-item copy, attestation, and local eviction remain + fail-closed; no Finder, provider, source, or cloud mutation occurred. + +## 2026-08-24 19:10 +0900 exact-head iCloud health receipt + +- The exact-head `disksage-icloud-sync-health` probe completed read-only with + `evidence_complete=true`, `new_copy_admission_state=blocked`, and + `pending_indexable_count=151283`; one upload is active at 95.24% and one download is active. + The report retains `provider_sync_attested=false`, `local_eviction_authorized=false`, and + `mutation_performed=false`. +- The blockers include native sync-up pending, 343 uploads blocked on sync-up, File Provider + indexing/disk-import/transfer activity, and filename/root exclusions. This is still aggregate + provider evidence rather than a per-item receipt for the seven Finder entries; no cloud write or + source eviction is authorized. + +## 2026-08-24 19:28 +0900 exact-head Git worktree audit repair + +- DiskSage #249 advanced to exact head `c8ca669262f913de5719ebda377132f1135c06c8`. The hosted + all-features compile failure was traced to CLI tests referencing the library's private + `MAX_REFERENCE_BYTES` bound; the bound is now exported once by the library and imported only by + the CLI test module. Pinned Rust 1.97.1 local proofs passed 7/7 CLI tests and 10/10 black-box + Git-worktree tests. +- The audit remains read-only, path-redacted, create-once for private evidence, and grants no + worktree-removal authority. The new exact head has no failed checks yet; hosted checks and + protected approvals remain authoritative. No user, Finder, provider, or cloud data was changed. + +## 2026-08-24 19:38 +0900 post-recovery iCloud recheck + +- A fresh read-only `/usr/bin/brctl status` still reports `client:needs-sync` and + `needs-sync-up|in-sync-down|prefer-sync-down|oob-sync-ack`; native last-sync remains + `2026-08-21 20:20:10.166`. +- Finder, `fileproviderd`, and `bird` are present with no DiskSage process; `/` has about 12 GiB + available. This remains aggregate provider reconciliation evidence for the Finder + `real_datasets` preparation stall, not proof of a DiskSage lock or a completed cloud write. + Copy admission, attestation, and source eviction remain fail-closed; no mutation was performed. + +## 2026-08-24 19:52 +0900 path-free lineage handoff proof + +- The buyer-visible P1 lineage gap now has a minimal export path: CloudArchive can download a + `disksage.cloud-lineage` JSON graph from a verified modern receipt, connecting source, + metadata, archive, provider, receipt, Goal, and (only after actual eviction) eviction nodes. +- The export includes stable content IDs, production metadata source/confidence, provider sync + state, and blockers, but no raw local or destination paths. Legacy receipts without a lineage + fingerprint fail closed. Frontend `npm run check`, all 137 frontend tests, and 100% V8 + statements/branches/functions/lines pass; the export itself is read-only and does not change + provider, source, cloud, ADR, or Goal state. + +- When remote content proof exists, the graph additionally binds a path-free provider-item node to + the provider and receipt; without it, no provider item is inferred from a local File Provider + path. + +## 2026-08-24 20:00 +0900 live Finder preparation diagnosis + +- The latest read-only `brctl status` still reports iCloud `client:needs-sync` with + `needs-sync-up|needs-sync-down|in-sync-down|prefer-sync-down|oob-sync-ack`; native last-sync + remains `2026-08-21 20:20:10.166`. Finder, `fileproviderd`, and `bird` are running, while no + DiskSage process is present. The persistent `pending-scan` queue and absent per-item receipt + keep the seven-item `real_datasets` operation at `provider-sync-incomplete`; no mutation was + performed. + +## 2026-08-24 20:18 +0900 candidate-scoped local headroom + +- The native-copy plan now records `local-volume-headroom-insufficient` or + `local-volume-headroom-unverified` on the individual candidate that failed its destination + filesystem probe. The UI retains the aggregate-notice fallback for older reports, so one large + file no longer disables smaller candidates that independently fit. Rust library tests passed + 741/741 (one live-provider test ignored) and the frontend passed 138/138 with 100% V8 coverage. + +- The replaceable Goal now explicitly carries `provider-sync-incomplete` and + `destination-headroom-bound`, so the persistent iCloud blocker and per-candidate staging gate + survive projection/restart without being reduced to an ambiguous pending label. + +## 2026-08-24 21:00 +0900 native iCloud pending-scan detection + +- The live `brctl status` evidence contains repeated `apply{[ pending-scan ... ]}` entries, but the + prior native-status schema did not expose that count to the admission report. DiskSage now + records the bounded, path-free `pending_scan_count`, emits + `icloud-native-status-pending-scan`, propagates it through Naruon readiness, and shows it in the + CloudArchive UI. This keeps the Finder `real_datasets` “복사 준비 중” state explicitly blocked + without treating the screenshot as an upload receipt; no Finder, provider, source, or cloud +mutation is performed. + +## 2026-08-25 00:00 +0900 dynamic Goal/ADR propagation + +- The previous native pending-scan implementation stopped at health/readiness/UI and did not update + receipt-linked runtime projections. The iCloud health persistence path now applies the selected + blocker to bounded valid iCloud receipt projections: Goal becomes `blocked` and revokes provider + completion and eviction gates; the paired ADR records the provider-state blocker. Projection + failures remain explicit and path-free, and no provider/Finder/source/cloud mutation occurs. + +## 2026-08-25 09:23 +0900 current Google Drive Finder-preparation diagnosis + +- The screenshot's destination is Google Drive, not iCloud. A bounded read-only + `fileproviderctl dump com.google.drivefs.fpext -l` reported `temporarily disconnected`, File + Provider `-1004` server-unreachable root metadata failures, active upload and download progress, + and a 2,000-entry reconciliation backlog. This is the provider-global explanation for Finder + remaining at “복사 준비 중”; it is not a per-item cloud receipt or proof of a completed copy. +- The 7.2 GiB `real_datasets` source remained local and unchanged, no destination folder or receipt + was observed, and the root volume had about 2.1 GiB free. DiskSage retains the existing stable + provider-global blockers and refuses copy, attestation, and source eviction until a fresh quiet + provider observation. No Finder, provider, source, or cloud mutation was performed. +- The exact DiskSage PR #247 head is + `9fdf2922da2939d96d3c2393539f2b2d42009929`; its hosted checks are still pending and the protected + PR remains draft/blocked/review-required. The host's `utun4` default route is recorded only as + context, not as a proven root cause. The filename dates `2026-04-28` and `251210` remain +auxiliary production-time evidence; embedded metadata and context retain precedence. + +## 2026-08-25 09:30 +0900 third-party provider blocker projection + +- Provider-global sync persistence now applies the existing monotonic ADR/Goal projection contract + to OneDrive and Google Drive. A blocked provider observation sets the linked Goal to `blocked`, + revokes provider-sync and eviction gates, and records the stable blocker in the paired ADR; + clear observations do not rewrite state. +- Reclaiming only disposable DiskSage Rust build artifacts increased root free space to about 3.5 + GiB, but the same Google Drive dump still reported `temporarily disconnected`, File Provider + `-1004`, active transfer markers, and 2,000 reconciliation entries. This confirms the current + stall remains provider-global rather than proven local fullness. No Finder/provider/source/cloud + mutation was performed. +- The exact DiskSage PR #247 head is `87c9089bcd4af49f8f8751c54ebcc45b519d1f0c`; hosted checks are + pending and the protected PR remains draft/blocked/review-required. Filename dates + `2026-04-28` and `251210` remain auxiliary production-time evidence only. + +## 2026-08-25 10:14 +0900 data-volume headroom and iCloud backlog recheck + +- `/Users` is the source/File Provider staging volume; it had about 594 MiB available before + disposable build-artifact cleanup and about 2.7 GiB after cleanup, while `real_datasets` is about + 7.2 GiB. The system-root `df` value is not a valid staging-volume authority. +- iCloud File Provider reported `pending-indexable-count: 490195`, upload/download progress entries + stuck at `0.0000`, and a 482,470-entry reconciliation section. The Finder preparation operation therefore remains + `provider-sync-incomplete`; it is not treated as a cloud receipt or completed upload. +- The Rust preview adapter now keeps unverified destination-ancestor results as diagnostics while + retaining candidate-specific insufficient-headroom blockers. Mutation-time destination probing + remains authoritative. Exact PR #247 head: `5c3b87359103b82df3efb4099668b1b17f532259`; hosted + checks are queued and the protected PR remains draft/blocked/review-required. No Finder, + provider, source, cloud, or eviction mutation was performed. + +## 2026-08-25 10:18 +0900 repeated zero-progress iCloud receipt + +- Two read-only iCloud probes 19 seconds apart increased `pending-indexable-count` from `492224` to + `492507` and reconciliation from `484500` to `484783`, while upload/download markers remained at + `Fraction completed: 0.0000`. +- No standalone `cp`, `ditto`, or `rsync` process was present. The visible Finder preparation + window is therefore provider coordination evidence, not proof of a DiskSage copy worker or a + completed cloud write. Goal remains `provider-sync-incomplete`; copy, attestation, cloud-write, + and source-eviction gates stay closed. No cancellation or provider/source/cloud mutation was + performed. + +## 2026-08-25 11:00 +0900 deterministic headroom regression proof + +- The preview adapter's candidate-scoped behavior is unchanged. Its regression fixture now uses an + intentionally unfit candidate size, preventing the test from accidentally treating the host + runner's root filesystem as verified capacity when the synthetic destination has no existing + ancestor. +- Pinned Rust 1.97.1 ran 745 library tests with one live-provider test ignored; the exact head is + `dc57a1539b82514f4ceb17ec0fca42ed23ae7988`. This is test evidence only and grants no cloud-write, + attestation, source-eviction, Finder-cancel, or provider-restart authority. + +## 2026-08-25 11:20 +0900 exact-head queue handoff + +- DiskSage PR #247 is now `8ad12e1e5b57944960b5389e4d2067f3fcd0e924`; its new hosted test, Strix, + Noema, and queue checks are pending. It remains draft, blocked, and review-required; the local + Rust proof above is not a substitute for hosted exact-head evidence or protected approval. +- DiskSage PR #249 remains at `2f1d585398b85f3f1adb3783520ad70e7b4a9c3f`; the stale Strix failure was + explicitly rerun against the same head after central `.github#1318` moved the smoke contract to + main. Other substantive checks are green, but the rerun and protected reviews are pending. +- Central `.github#1318` merged as `8fd471a31399a914d9cb22a840f4a4c68e010ea6`; `.github#1316` is + based on that head at `e4f9865a1b06978324f006ee3861b84953877d8b` and carries the remaining direct + OpenCode model-pool alignment. No merge or approval is inferred from queued checks or bot reviews. + +## 2026-08-25 11:06 +0900 current Finder “복사 준비 중” receipt + +- A fresh bounded read-only File Provider dump reports Google Drive as `temporarily disconnected`; + the root metadata request returns File Provider `-1004` (server unreachable), the reconciliation + queue is capped at 2,000 entries, and the latest user-initiated root retry is approximately + 57 minutes old. Upload/download markers are present, but there is no per-item destination receipt. +- iCloud is also backlogged: `pending-indexable-count` is 505,103, upload/download progress is + `0.0000`, `disk import` is active, and reconciliation contains 497,379 entries. The data volume + has approximately 20 GiB free at this observation, so the Finder wait is provider coordination, + not proof that the local volume is full. +- DiskSage must display this as `provider-sync-incomplete` and keep cloud write, attestation, source + eviction, provider restart, and Finder cancellation blocked. The screenshot is not a cloud receipt; + no Finder, provider, source, cloud, or eviction mutation was performed. Filename dates remain + auxiliary evidence only; embedded metadata and context retain precedence. + +## 2026-08-25 11:09 +0900 persistent provider stall recheck + +- The next bounded read-only probe still finds Google Drive temporarily disconnected with File + Provider `-1004`, a 2,000-entry reconciliation cap, and active upload/download markers. iCloud + grew to `pending-indexable-count` 506,044 and 498,320 reconciliation entries while both transfer + fractions remain `0.0000`; disk import and stream reset remain active. +- The data volume remains approximately 20 GiB free. This is persistent provider coordination, + not evidence that the Finder dialog completed or that the local volume is full. DiskSage keeps + `provider-sync-incomplete`, cloud write, attestation, source eviction, provider restart, and + Finder cancellation blocked. +- Current exact-head queue evidence: PR #247 `8e98b74e` (draft/blocked/review-required; hosted + checks pending), PR #246 `1972614e` (draft/blocked/review-required; prior Strix HTTP 429/404 + infrastructure failure rerun requested), PR #249 `2f1d585` (draft/blocked/review-required; + Strix rerun pending), and central `.github#1316` `e4f9865a` (blocked with no qualifying approval; + required checks pending). No merge is inferred. + +## 2026-08-25 11:24 +0900 provider-indexing Finder action gap closed + +- Provider-global indexing-only stalls now expose the same bounded Finder-cancel action as transfer + and reconciliation stalls. This covers OneDrive/Google Drive reports of + `provider-global-sync-indexing-pending` without treating the provider dump as a copy receipt. +- Svelte type-check and the focused CloudArchive admission/timing tests passed. The exact PR #247 + head is `dda0f1d5`; its hosted checks restart on the documentation head. No automatic Finder + cancellation, provider restart, cloud write, source mutation, or eviction was performed. diff --git a/src-tauri/src/cloud.rs b/src-tauri/src/cloud.rs index 8eb339654..e6842ff77 100644 --- a/src-tauri/src/cloud.rs +++ b/src-tauri/src/cloud.rs @@ -5256,9 +5256,12 @@ pub fn plan_cloud_archive_from_snapshot( } #[cfg(not(coverage))] let exact_duplicates = { + // Detect clusters before applying the presentation limit so a duplicate pair split across + // the boundary still blocks automatic handling of the visible member. + let exact_duplicates = mark_exact_duplicate_candidates(&mut candidates, None); candidates.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.src.cmp(&b.src))); candidates.truncate(options.limit); - mark_exact_duplicate_candidates(&mut candidates, None) + exact_duplicates }; #[cfg(coverage)] let exact_duplicates = { @@ -5266,12 +5269,6 @@ pub fn plan_cloud_archive_from_snapshot( candidates.truncate(options.limit); ExactDuplicateSummary::default() }; - let candidate_bytes = candidates.iter().map(|c| c.bytes).sum(); - let potentially_reclaimable_bytes = candidates - .iter() - .filter(|c| c.blocked_reason.is_none()) - .map(|c| c.bytes) - .sum(); let local_volume = crate::volume_pressure::snapshot_volume(source_root, now_ms).ok(); let mut notices = vec![ "dry-run-only".into(), @@ -5280,13 +5277,40 @@ pub fn plan_cloud_archive_from_snapshot( "cloud-sync-unverified".into(), "full-transfer-content-hash-pending".into(), ]; - if local_volume.as_ref().is_some_and(|volume| { - candidates.iter().any(|candidate| { - !crate::volume_pressure::has_copy_headroom(volume.available_bytes, candidate.bytes) - }) - }) { + let mut destination_headroom_insufficient = false; + let mut destination_headroom_unverified = false; + for candidate in candidates + .iter_mut() + .filter(|candidate| candidate.blocked_reason.is_none()) + { + match crate::copy_headroom::require_destination_copy_headroom( + Path::new(&candidate.dst), + candidate.bytes, + now_ms, + ) { + Ok(()) => {} + Err(reason) if reason == "local-volume-headroom-insufficient" => { + candidate.blocked_reason = Some(reason); + destination_headroom_insufficient = true; + } + Err(reason) => { + candidate.blocked_reason = Some(reason); + destination_headroom_unverified = true; + } + } + } + if destination_headroom_insufficient { notices.push("local-volume-headroom-insufficient".into()); } + if destination_headroom_unverified { + notices.push("local-volume-headroom-unverified".into()); + } + let candidate_bytes = candidates.iter().map(|c| c.bytes).sum(); + let potentially_reclaimable_bytes = candidates + .iter() + .filter(|c| c.blocked_reason.is_none()) + .map(|c| c.bytes) + .sum(); if !snapshot.source_scan_complete { notices.push("source-scan-incomplete".into()); notices.push(format!( @@ -5344,40 +5368,6 @@ mod tests { } } - #[cfg(not(coverage))] - #[test] - fn folded_received_header_at_end_of_block_is_safe() { - let temp = tempfile::tempdir().unwrap(); - let message_path = temp.path().join("folded-received.eml"); - std::fs::write( - &message_path, - concat!( - "Date: Mon, 17 Aug 2026 12:00:00 +0000\r\n", - "Subject: Folded Received regression\r\n", - "Received: from relay.example\r\n", - "\tby mx.example with ESMTP\r\n", - "\r\n", - "body is deliberately outside the bounded metadata parser\r\n", - ), - ) - .unwrap(); - - let metadata = probe_content_metadata_with_general(&message_path, None); - assert_eq!( - metadata.title.as_deref(), - Some("Folded Received regression") - ); - assert!(metadata.evidence.iter().any(|evidence| { - evidence.field == "email-header-bytes-inspected" - && evidence.source == "local:metadata-probe:bounded-rfc5322-header" - })); - assert!(metadata.evidence.iter().any(|evidence| { - evidence.field == "email-body-inspected" - && evidence.value == "false" - && evidence.source == "local:metadata-probe:bounded-rfc5322-header" - })); - } - #[cfg(not(coverage))] #[test] fn exiftool_batch_documents_bind_each_source_file_and_reject_duplicates() { @@ -5762,11 +5752,70 @@ mod tests { }, ); - assert_eq!(report.candidates[0].blocked_reason, None); + assert_eq!( + report.candidates[0].blocked_reason.as_deref(), + Some("local-volume-headroom-insufficient") + ); assert!(report .notices .contains(&"local-volume-headroom-insufficient".to_string())); - assert_eq!(report.potentially_reclaimable_bytes, u64::MAX); + assert_eq!(report.potentially_reclaimable_bytes, 0); + } + + #[cfg(not(coverage))] + #[test] + fn destination_headroom_blocks_only_the_candidate_that_does_not_fit() { + let tmp = tempfile::tempdir().unwrap(); + let source_root = tmp.path().join("source"); + let cloud_root = tmp.path().join("cloud"); + writable_dir(&source_root); + writable_dir(&cloud_root); + let report = plan_cloud_archive( + &[ + FileFact { + path: source_root.join("large.zip"), + bytes: u64::MAX / 2, + created_ms: 1, + modified_ms: 1, + content_metadata: ContentMetadata::default(), + }, + FileFact { + path: source_root.join("small.zip"), + bytes: 1, + created_ms: 1, + modified_ms: 1, + content_metadata: ContentMetadata::default(), + }, + ], + &source_root, + &root(CloudProvider::GoogleDrive, &cloud_root), + system_now_ms(), + CloudPlanOptions { + min_size_bytes: 1, + min_age_days: 0, + limit: 10, + }, + ); + + let large = report + .candidates + .iter() + .find(|candidate| candidate.src.ends_with("large.zip")) + .unwrap(); + let small = report + .candidates + .iter() + .find(|candidate| candidate.src.ends_with("small.zip")) + .unwrap(); + assert_eq!( + large.blocked_reason.as_deref(), + Some("local-volume-headroom-insufficient") + ); + assert_ne!( + small.blocked_reason.as_deref(), + Some("local-volume-headroom-insufficient") + ); + assert_eq!(report.potentially_reclaimable_bytes, small.bytes); } #[cfg(not(coverage))] @@ -7105,6 +7154,65 @@ mod tests { .contains(&"exact-duplicate-content-needs-canonical-selection".to_string())); } + #[cfg(not(coverage))] + #[test] + fn planner_detects_duplicate_pairs_split_by_candidate_limit() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + let cloud = tmp.path().join("cloud"); + writable_dir(&source); + writable_dir(&cloud); + std::fs::write(source.join("large.pdf"), b"larger-than-duplicates").unwrap(); + for name in ["a-duplicate.pdf", "z-duplicate.pdf"] { + std::fs::write(source.join(name), b"same-content").unwrap(); + } + let production_time_ms = date_epoch_ms(2026, 1, 2).unwrap(); + let metadata = ContentMetadata { + production_time_ms: Some(production_time_ms), + production_time_source: Some("embedded:test:creation-date".into()), + production_time_confidence: Some("high".into()), + ..ContentMetadata::default() + }; + let files = ["large.pdf", "a-duplicate.pdf", "z-duplicate.pdf"] + .into_iter() + .map(|name| { + let path = source.join(name); + let file_metadata = std::fs::metadata(&path).unwrap(); + FileFact { + path, + bytes: file_metadata.len(), + created_ms: millis(file_metadata.created()), + modified_ms: millis(file_metadata.modified()), + content_metadata: metadata.clone(), + } + }) + .collect::>(); + + let report = plan_cloud_archive( + &files, + &source, + &root(CloudProvider::GoogleDrive, &cloud), + system_now_ms() + DAY_MS, + CloudPlanOptions { + min_size_bytes: 0, + min_age_days: 0, + limit: 2, + }, + ); + + assert_eq!(report.candidates.len(), 2); + assert_eq!(report.exact_duplicates.cluster_count, 1); + assert_eq!(report.exact_duplicates.candidate_count, 2); + let visible_duplicate = report + .candidates + .iter() + .find(|candidate| candidate.relative_path == "a-duplicate.pdf") + .unwrap(); + assert!(visible_duplicate + .review_reasons + .contains(&"exact-duplicate-content-needs-canonical-selection".to_string())); + } + #[cfg(not(coverage))] #[test] fn canonical_recommendation_keeps_embedded_lineage_ahead_of_copy_name_heuristics() { diff --git a/src-tauri/src/cloud_plan_view.rs b/src-tauri/src/cloud_plan_view.rs index 052b52428..bc15aa92a 100644 --- a/src-tauri/src/cloud_plan_view.rs +++ b/src-tauri/src/cloud_plan_view.rs @@ -14,6 +14,74 @@ use crate::cloud_transfer::{ }; use crate::provider_capacity::CloudCapacityAssessment; use crate::volume_pressure::LocalVolumeSnapshot; +use std::path::Path; + +const PLAN_WIDE_HEADROOM_BLOCKERS: [&str; 2] = [ + "local-volume-headroom-insufficient", + "local-volume-headroom-unverified", +]; +const PARTIAL_HEADROOM_NOTICE: &str = "local-volume-headroom-partial"; + +/// Keep the desktop plan's plan-wide headroom notices honest at candidate granularity. +/// +/// The core planner records a stable aggregate notice whenever any candidate's destination probe +/// fails. The desktop historically treated those aggregate notices as blanket copy-button gates, +/// even though mutation-time native copy revalidates headroom for the selected candidate. If at +/// least one otherwise-unblocked candidate has verified destination/staging headroom, replace the +/// blanket blocker with a non-blocking partial diagnostic. Plans where no candidate can establish +/// headroom keep the original fail-closed blocker. This does not grant mutation authority: the +/// per-candidate copy boundary still performs the authoritative probe immediately before staging. +pub fn normalize_native_copy_headroom_notices(report: &mut CloudPlanReport) { + if !report + .notices + .iter() + .any(|notice| PLAN_WIDE_HEADROOM_BLOCKERS.contains(¬ice.as_str())) + { + return; + } + + let has_verified_candidate = report + .candidates + .iter() + .filter(|candidate| candidate.blocked_reason.is_none()) + .any(|candidate| { + crate::copy_headroom::require_destination_copy_headroom( + Path::new(&candidate.dst), + candidate.bytes, + report.generated_at_ms, + ) + .is_ok() + }); + if !has_verified_candidate { + return; + } + + // An unsafe destination ancestor is a preview diagnostic, not a copy denial, but only when + // another candidate has established destination headroom. Without that proof, retain the + // candidate blocker so the serialized action cannot advertise an approval phrase for an + // unverified staging path. The mutation boundary still re-probes the exact path immediately + // before staging. + for candidate in &mut report.candidates { + if candidate + .blocked_reason + .as_deref() + .is_some_and(|reason| reason.starts_with("local-volume-headroom-destination-")) + { + candidate.blocked_reason = None; + } + } + + report + .notices + .retain(|notice| !PLAN_WIDE_HEADROOM_BLOCKERS.contains(¬ice.as_str())); + if !report + .notices + .iter() + .any(|notice| notice == PARTIAL_HEADROOM_NOTICE) + { + report.notices.push(PARTIAL_HEADROOM_NOTICE.into()); + } +} /// One cloud candidate plus the backend-authored approval presentation for its current state. #[derive(Debug, Clone, serde::Serialize)] @@ -23,7 +91,7 @@ pub struct CloudPlanCandidateView { pub candidate: CloudCandidate, /// Exact action available for this candidate, or `None` when another blocker applies. pub copy_approval_action: Option, - /// Candidate-specific confirmation phrase generated by Rust for the available action. + /// Candidate-specific confirmation phrase generated by Rust, or null when blocked. pub exact_copy_approval_phrase: Option, /// Maximum age, in milliseconds, accepted for an approval created from this plan. pub copy_approval_max_age_ms: u64, @@ -79,7 +147,8 @@ pub struct CloudPlanReportView { } impl From for CloudPlanReportView { - fn from(report: CloudPlanReport) -> Self { + fn from(mut report: CloudPlanReport) -> Self { + normalize_native_copy_headroom_notices(&mut report); let CloudPlanReport { cloud_root, generated_at_ms, @@ -196,6 +265,45 @@ mod tests { assert!(serialized["exact_copy_approval_phrase"].is_null()); } + #[test] + fn normalization_keeps_unverified_destination_headroom_preview_block() { + let mut report = CloudPlanReport { + cloud_root: CloudRoot { + id: "google-drive-personal".into(), + provider: CloudProvider::GoogleDrive, + account_scope: CloudAccountScope::Personal, + label: "Google Drive".into(), + path: "/cloud".into(), + readable: true, + access_issue: None, + }, + generated_at_ms: 42, + source_selection_policy: Some(CloudPlanOptions::default()), + candidates: vec![candidate(Some( + "local-volume-headroom-destination-parent-unsafe", + ))], + candidate_bytes: 4096, + potentially_reclaimable_bytes: 0, + exact_duplicates: ExactDuplicateSummary::default(), + capacity: None, + local_volume: None, + pre_copy_evidence: None, + notices: vec!["local-volume-headroom-unverified".into()], + }; + report.candidates[0].bytes = u64::MAX; + report.candidate_bytes = u64::MAX; + + normalize_native_copy_headroom_notices(&mut report); + + assert_eq!( + report.candidates[0].blocked_reason.as_deref(), + Some("local-volume-headroom-destination-parent-unsafe") + ); + assert!(report + .notices + .contains(&"local-volume-headroom-unverified".to_string())); + } + #[test] fn report_conversion_preserves_plan_evidence_and_enriches_candidates() { let report = CloudPlanReport { diff --git a/src-tauri/src/cloud_transfer.rs b/src-tauri/src/cloud_transfer.rs index 25f2668ca..a452a0244 100644 --- a/src-tauri/src/cloud_transfer.rs +++ b/src-tauri/src/cloud_transfer.rs @@ -672,6 +672,28 @@ pub fn candidate_blockers_with_review( candidate_blockers_for_action(candidate, cloud_root, review_decision, false) } +/// Validate a provider-API copy while allowing only native staging headroom diagnostics. +/// +/// Provider API uploads stream the source directly to the remote service and do not create the +/// local File Provider staging file whose capacity probe produced `local-volume-headroom-*`. +/// Every other planner blocker remains authoritative, including review, path, provider, and +/// metadata gates. +pub fn provider_api_candidate_blockers_with_review( + candidate: &CloudCandidate, + cloud_root: &CloudRoot, + review_decision: Option<&CloudReviewDecision>, +) -> Vec { + let mut blockers = candidate_blockers_for_action(candidate, cloud_root, review_decision, false); + if candidate + .blocked_reason + .as_deref() + .is_some_and(|reason| reason.starts_with("local-volume-headroom-")) + { + blockers.retain(|blocker| blocker != "planner-blocked"); + } + blockers +} + /// Validate a fresh planner candidate for adopting a destination that already exists. This clears /// only the exact `destination-exists` planner condition; every metadata, review, account-scope, /// and path gate remains identical to a DiskSage-created copy. @@ -2533,6 +2555,17 @@ mod tests { .contains(&"source-already-in-cloud-root".to_string())); } + #[test] + fn provider_api_copy_bypasses_only_native_staging_headroom() { + let mut candidate = candidate(); + candidate.blocked_reason = Some("local-volume-headroom-insufficient".into()); + assert!(provider_api_candidate_blockers_with_review(&candidate, &root(), None).is_empty()); + + candidate.blocked_reason = Some("destination-exists".into()); + assert!(provider_api_candidate_blockers_with_review(&candidate, &root(), None) + .contains(&"planner-blocked".to_string())); + } + #[test] #[cfg(not(coverage))] fn production_copy_entrypoints_recheck_approval_age_against_live_time() { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4265d7751..580df1bf8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -31,9 +31,6 @@ use crate::{ #[path = "home_resolution.rs"] mod home_resolution; -#[path = "copy_headroom.rs"] -mod copy_headroom; - #[derive(Default)] pub struct AppState { pub result: Arc>>, @@ -1263,7 +1260,7 @@ pub fn inspect_icloud_new_copy_admission( ) -> Result { let home = resolve_home(&app)?; let mut report = icloud_sync_health::inspect_new_copy_admission(&home, cloud::system_now_ms())?; - if !persist_icloud_health_evidence(&app, &report) { + if !persist_icloud_health_evidence(&app, &mut report) { report .notices .push("icloud-sync-health-evidence-persistence-failed".into()); @@ -1283,7 +1280,13 @@ pub fn inspect_cloud_provider_global_sync( if selected.provider == cloud::CloudProvider::Icloud { return Err("provider-global-sync-icloud-specialized".into()); } - provider_global_sync::inspect_new_copy_admission(selected.provider) + let mut report = provider_global_sync::inspect_new_copy_admission(selected.provider)?; + if !persist_provider_global_sync_evidence(&app, &mut report) { + report + .notices + .push("provider-global-sync-evidence-persistence-failed".into()); + } + Ok(report) } #[cfg(not(coverage))] @@ -1297,15 +1300,157 @@ struct CloudPlanningOutput { #[cfg(not(coverage))] fn persist_icloud_health_evidence( app: &AppHandle, - report: &icloud_sync_health::IcloudSyncHealthReport, + report: &mut icloud_sync_health::IcloudSyncHealthReport, ) -> bool { - app.path() - .app_data_dir() - .ok() - .and_then(|app_data_dir| { - icloud_sync_health::write_icloud_sync_health_evidence(&app_data_dir, report).ok() - }) - .is_some() + let Some(app_data_dir) = app.path().app_data_dir().ok() else { + return false; + }; + if icloud_sync_health::write_icloud_sync_health_evidence(&app_data_dir, report).is_err() { + return false; + } + report.admission_blocked_since_ms = + icloud_sync_health::admission_blocked_since_ms(&app_data_dir, report); + let provider_blocker = report + .new_copy_admission_blockers + .iter() + .find(|blocker| blocker.as_str() == "icloud-native-status-pending-scan") + .or_else(|| report.new_copy_admission_blockers.first()); + if let Some(provider_blocker) = provider_blocker { + report.notices.extend(update_provider_goal_projections( + &app_data_dir.join("cloud-receipts"), + &app_data_dir.join("cloud-adr"), + &app_data_dir.join("cloud-goals"), + cloud::system_now_ms(), + cloud::CloudProvider::Icloud, + provider_blocker, + )); + } + true +} + +#[cfg(not(coverage))] +fn apply_provider_blocker_to_projection( + receipt: &cloud_transfer::CloudCopyReceipt, + adr_dir: &Path, + goal_dir: &Path, + observed_at_ms: u64, + provider_blocker: &str, +) -> cloud_adr::ProjectionWriteOutcome { + cloud_adr::ensure_initial_projection_pair_with_provider_state_outcome( + receipt, + adr_dir, + goal_dir, + observed_at_ms, + provider_blocker, + ) +} + +#[cfg(not(coverage))] +fn update_provider_goal_projections( + receipt_dir: &Path, + adr_dir: &Path, + goal_dir: &Path, + observed_at_ms: u64, + provider: cloud::CloudProvider, + provider_blocker: &str, +) -> Vec { + match std::fs::symlink_metadata(receipt_dir) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => return vec!["dynamic-goal-projection-update-incomplete".into()], + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(), + Err(_) => return vec!["dynamic-goal-projection-update-incomplete".into()], + } + let mut paths = match std::fs::read_dir(receipt_dir) { + Ok(entries) => entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .collect::>(), + Err(_) => return vec!["dynamic-goal-projection-update-incomplete".into()], + }; + paths.sort(); + if paths.len() > MAX_CLOUD_RECEIPT_RECONCILIATION_ENTRIES { + return vec!["dynamic-goal-projection-update-incomplete".into()]; + } + let started = Instant::now(); + let mut updated = 0usize; + let mut incomplete = false; + for (index, path) in paths.iter().enumerate() { + if index >= MAX_CLOUD_RECEIPTS_PER_RECONCILIATION + || started.elapsed() >= CLOUD_RECONCILIATION_MAX_DURATION + { + incomplete = true; + break; + } + let Ok(file_metadata) = std::fs::symlink_metadata(path) else { + incomplete = true; + continue; + }; + if file_metadata.file_type().is_symlink() + || !file_metadata.is_file() + || path.extension().and_then(|value| value.to_str()) != Some("json") + { + continue; + } + let receipt = match cloud_transfer::read_immutable_receipt(path) { + Ok(receipt) => receipt, + Err(_) => { + incomplete = true; + continue; + } + }; + if receipt.provider != provider { + continue; + } + let outcome = apply_provider_blocker_to_projection( + &receipt, + adr_dir, + goal_dir, + observed_at_ms, + provider_blocker, + ); + if outcome.wrote { + updated = updated.saturating_add(1); + } + incomplete |= !outcome.warnings.is_empty(); + } + let mut notices = Vec::new(); + if updated > 0 { + notices.push("dynamic-goal-projection-updated".into()); + } + if incomplete { + notices.push("dynamic-goal-projection-update-incomplete".into()); + } + notices +} + +#[cfg(not(coverage))] +fn persist_provider_global_sync_evidence( + app: &AppHandle, + report: &mut provider_global_sync::ProviderGlobalSyncReport, +) -> bool { + let Some(app_data_dir) = app.path().app_data_dir().ok() else { + return false; + }; + if provider_global_sync::write_provider_global_sync_evidence(&app_data_dir, report).is_err() { + return false; + } + report.admission_blocked_since_ms = + provider_global_sync::provider_global_sync_blocked_since_ms(&app_data_dir, report); + let provider_blocker = report.blockers.first().cloned().or_else(|| { + (report.state != provider_global_sync::ProviderGlobalSyncState::Clear) + .then(|| format!("provider-global-sync-{}", report.state.as_str())) + }); + if let Some(provider_blocker) = provider_blocker.as_deref() { + report.notices.extend(update_provider_goal_projections( + &app_data_dir.join("cloud-receipts"), + &app_data_dir.join("cloud-adr"), + &app_data_dir.join("cloud-goals"), + report.observed_at_ms, + report.provider, + provider_blocker, + )); + } + true } #[cfg(not(coverage))] @@ -1461,8 +1606,9 @@ fn cloud_plan_for_inputs( } let (icloud_health, provider_global_sync) = if selected.provider == cloud::CloudProvider::Icloud { - let health = icloud_sync_health::inspect_new_copy_admission(&home, cloud::system_now_ms()).ok(); - if let Some(health) = health.as_ref() { + let mut health = + icloud_sync_health::inspect_new_copy_admission(&home, cloud::system_now_ms()).ok(); + if let Some(health) = health.as_mut() { if !persist_icloud_health_evidence(app, health) { report .notices @@ -1472,7 +1618,14 @@ fn cloud_plan_for_inputs( icloud_sync_health::attach_new_copy_admission_notice(&mut report.notices, health.as_ref()); (health, None) } else { - let global_sync = provider_global_sync::inspect_new_copy_admission(selected.provider).ok(); + let mut global_sync = provider_global_sync::inspect_new_copy_admission(selected.provider).ok(); + if let Some(global_sync) = global_sync.as_mut() { + if !persist_provider_global_sync_evidence(app, global_sync) { + report + .notices + .push("provider-global-sync-evidence-persistence-failed".into()); + } + } provider_global_sync::attach_new_copy_admission_notice( &mut report.notices, global_sync.as_ref(), @@ -1589,7 +1742,7 @@ fn require_capacity_for_copy( #[cfg(not(coverage))] fn require_local_copy_headroom(candidate: &cloud::CloudCandidate) -> Result<(), String> { - copy_headroom::require_destination_copy_headroom( + crate::copy_headroom::require_destination_copy_headroom( Path::new(&candidate.dst), candidate.bytes, cloud::system_now_ms(), @@ -1996,6 +2149,14 @@ fn create_cloud_candidate_provider_api_receipt( } else { None }; + let blockers = cloud_transfer::provider_api_candidate_blockers_with_review( + candidate, + &selected, + review_decision.as_ref(), + ); + if !blockers.is_empty() { + return Err(format!("provider-api-candidate-blocked:{}", blockers.join(","))); + } let copy_approval = cloud_transfer::create_cloud_copy_approval( candidate, &selected, @@ -3282,6 +3443,177 @@ mod tests { ); } + #[cfg(not(coverage))] + #[test] + fn icloud_health_blocker_updates_dynamic_goal_and_adr_projections() { + let temporary = tempfile::tempdir().unwrap(); + let receipt = cloud_transfer::CloudCopyReceipt { + version: cloud_transfer::RECEIPT_VERSION, + receipt_id: "a".repeat(64), + candidate_fingerprint: "b".repeat(64), + provider: cloud::CloudProvider::Icloud, + source: "/source/file.bin".into(), + destination: "/cloud/file.bin".into(), + bytes: 1, + blake3: "c".repeat(64), + sha256: "d".repeat(64), + quick_xor_base64: String::new(), + source_modified_ms: 1, + copied_at_ms: 2, + copy_verified: true, + provider_sync_confirmed: false, + lineage_fingerprint: None, + lineage: None, + }; + let adr_dir = temporary.path().join("adr"); + let goal_dir = temporary.path().join("goals"); + let initial = cloud_adr::write_projection_pair( + &adr_dir, + &cloud_adr::initial_adr_snapshot(&receipt, 2), + &goal_dir, + &cloud_adr::initial_goal_snapshot(&receipt, 2), + ); + assert!(initial.0.is_some() && initial.1.is_some()); + + let outcome = apply_provider_blocker_to_projection( + &receipt, + &adr_dir, + &goal_dir, + 3, + "icloud-native-status-pending-scan", + ); + assert!(outcome.wrote); + assert!(outcome.warnings.is_empty()); + + let goal: cloud_adr::CloudOffloadGoalSnapshot = serde_json::from_slice( + &std::fs::read(goal_dir.join(format!("{}-latest.json", receipt.receipt_id))).unwrap(), + ) + .unwrap(); + assert_eq!(goal.status, "blocked"); + assert!(!goal.completion_gates["provider-sync-state-complete"]); + assert!(!goal.completion_gates["explicit-eviction-permit"]); + let adr: cloud_adr::CloudOffloadAdrSnapshot = serde_json::from_slice( + &std::fs::read(adr_dir.join(format!("{}-latest.json", receipt.receipt_id))).unwrap(), + ) + .unwrap(); + assert!(adr + .consequences + .iter() + .any(|value| value == "provider-state-blocked:icloud-native-status-pending-scan")); + } + + #[cfg(not(coverage))] + #[test] + fn provider_global_sync_blocker_updates_google_drive_goal_and_adr_projections() { + let temporary = tempfile::tempdir().unwrap(); + let receipt = cloud_transfer::CloudCopyReceipt { + version: cloud_transfer::RECEIPT_VERSION, + receipt_id: "e".repeat(64), + candidate_fingerprint: "f".repeat(64), + provider: cloud::CloudProvider::GoogleDrive, + source: "/source/file.zip".into(), + destination: "/google-drive/file.zip".into(), + bytes: 1, + blake3: "a".repeat(64), + sha256: "b".repeat(64), + quick_xor_base64: String::new(), + source_modified_ms: 1, + copied_at_ms: 2, + copy_verified: true, + provider_sync_confirmed: false, + lineage_fingerprint: None, + lineage: None, + }; + let adr_dir = temporary.path().join("adr"); + let goal_dir = temporary.path().join("goals"); + let outcome = apply_provider_blocker_to_projection( + &receipt, + &adr_dir, + &goal_dir, + 3, + "provider-global-sync-temporarily-disconnected", + ); + assert!(outcome.wrote); + assert!(outcome.warnings.is_empty()); + + let goal: cloud_adr::CloudOffloadGoalSnapshot = serde_json::from_slice( + &std::fs::read(goal_dir.join(format!("{}-latest.json", receipt.receipt_id))).unwrap(), + ) + .unwrap(); + assert_eq!(goal.status, "blocked"); + assert!(!goal.completion_gates["provider-sync-state-complete"]); + assert!(!goal.completion_gates["explicit-eviction-permit"]); + let adr: cloud_adr::CloudOffloadAdrSnapshot = serde_json::from_slice( + &std::fs::read(adr_dir.join(format!("{}-latest.json", receipt.receipt_id))).unwrap(), + ) + .unwrap(); + assert!(adr.consequences.iter().any(|value| { + value == "provider-state-blocked:provider-global-sync-temporarily-disconnected" + })); + } + + #[cfg(not(coverage))] + #[test] + fn provider_goal_projection_scans_the_runtime_receipt_directory() { + let temporary = tempfile::tempdir().unwrap(); + let receipt_dir = temporary.path().join("cloud-receipts"); + let adr_dir = temporary.path().join("cloud-adr"); + let goal_dir = temporary.path().join("cloud-goals"); + let mut receipt = cloud_transfer::CloudCopyReceipt { + version: cloud_transfer::LEGACY_RECEIPT_VERSION, + receipt_id: String::new(), + candidate_fingerprint: "h".repeat(64), + provider: cloud::CloudProvider::GoogleDrive, + source: "/source/file.zip".into(), + destination: "/google-drive/file.zip".into(), + bytes: 1, + blake3: "i".repeat(64), + sha256: "j".repeat(64), + quick_xor_base64: String::new(), + source_modified_ms: 1, + copied_at_ms: 2, + copy_verified: true, + provider_sync_confirmed: false, + lineage_fingerprint: None, + lineage: None, + }; + let mut receipt_id = blake3::Hasher::new(); + receipt_id.update(&receipt.version.to_le_bytes()); + receipt_id.update(receipt.candidate_fingerprint.as_bytes()); + receipt_id.update(&[0]); + receipt_id.update(receipt.provider.as_str().as_bytes()); + receipt_id.update(&[0]); + receipt_id.update(receipt.source.as_bytes()); + receipt_id.update(&[0]); + receipt_id.update(receipt.destination.as_bytes()); + receipt_id.update(&[0]); + receipt_id.update(&receipt.bytes.to_le_bytes()); + receipt_id.update(receipt.blake3.as_bytes()); + receipt_id.update(receipt.sha256.as_bytes()); + receipt_id.update(receipt.quick_xor_base64.as_bytes()); + receipt_id.update(&receipt.source_modified_ms.to_le_bytes()); + receipt_id.update(&receipt.copied_at_ms.to_le_bytes()); + receipt_id.update(&[receipt.copy_verified as u8, receipt.provider_sync_confirmed as u8]); + receipt.receipt_id = receipt_id.finalize().to_hex().to_string(); + + cloud_transfer::write_provider_api_receipt(&receipt, &receipt_dir).unwrap(); + let notices = update_provider_goal_projections( + &receipt_dir, + &adr_dir, + &goal_dir, + 3, + cloud::CloudProvider::GoogleDrive, + "provider-global-sync-temporarily-disconnected", + ); + assert!(notices.iter().any(|notice| notice == "dynamic-goal-projection-updated")); + + let goal: cloud_adr::CloudOffloadGoalSnapshot = serde_json::from_slice( + &std::fs::read(goal_dir.join(format!("{}-latest.json", receipt.receipt_id))).unwrap(), + ) + .unwrap(); + assert_eq!(goal.status, "blocked"); + } + #[cfg(not(coverage))] #[test] fn reconciliation_without_receipts_is_read_only() { diff --git a/src-tauri/src/icloud_sync_health.rs b/src-tauri/src/icloud_sync_health.rs index e0bf643ea..43f37d4f5 100644 --- a/src-tauri/src/icloud_sync_health.rs +++ b/src-tauri/src/icloud_sync_health.rs @@ -43,8 +43,11 @@ const FILEPROVIDERCTL_PATH: &str = "/usr/bin/fileproviderctl"; const FILEPROVIDER_DUMP_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(target_os = "macos")] // Keep the sync summary and a larger bounded provider-error window together; iCloud places -// filename/root exclusion diagnostics after the aggregate summary in large dumps. -const MAX_FILEPROVIDER_DUMP_BYTES: usize = 1024 * 1024; +// filename/root exclusion diagnostics after the aggregate summary in large dumps. Match the +// sibling provider_global_sync probe's cap: real fileproviderctl dumps observed in the field +// run several MiB, and the previous 1 MiB cap routinely truncated before reaching per-item +// exclusion/materialization markers that follow the aggregate summary. +const MAX_FILEPROVIDER_DUMP_BYTES: usize = 32 * 1024 * 1024; const ITEM_ERROR_AGE_NOTICE_MS: u64 = 86_400_000; const FILE_PROVIDER_STALE_ERROR_AGE_MS: u64 = 15 * 60 * 1_000; static SNAPSHOT_NONCE: AtomicU64 = AtomicU64::new(0); @@ -54,6 +57,7 @@ pub const ICLOUD_NATIVE_STATUS_SCHEMA_VERSION: u32 = 1; pub const ICLOUD_FILE_PROVIDER_ACTIVITY_SCHEMA_VERSION: u32 = 3; pub const ICLOUD_SYNC_HEALTH_EVIDENCE_SCHEMA_VERSION: u32 = 1; pub const ICLOUD_SYNC_HEALTH_EVIDENCE_DIRECTORY: &str = "icloud-sync-health-evidence"; +pub(crate) const FILE_PROVIDER_DISK_IMPORT_NOTICE: &str = "icloud-file-provider-disk-import-active"; const MAX_PERSISTED_HEALTH_SNAPSHOTS: usize = 128; const MAX_PERSISTED_HEALTH_SNAPSHOT_BYTES: usize = 64 * 1024; @@ -132,6 +136,9 @@ pub struct IcloudNativeStatusEvidence { pub server_state: Option, pub sync_state: Option, pub last_sync_present: bool, + /// Number of bounded `brctl status` apply entries waiting on a provider scan. + #[serde(default)] + pub pending_scan_count: u64, pub notices: Vec, } @@ -156,6 +163,9 @@ pub struct IcloudFileProviderActivityEvidence { /// Aggregate provider errors where iCloud excludes an item under a sync root. #[serde(default)] pub sync_excluded_root_count: u64, + /// Aggregate File Provider metadata work still waiting to be indexed. + #[serde(default)] + pub pending_indexable_count: Option, #[serde(default)] pub active_upload_count: u64, #[serde(default)] @@ -242,11 +252,21 @@ pub fn native_sync_down_pending(evidence: &IcloudNativeStatusEvidence) -> bool { .is_some_and(|state| state.split('|').any(|value| value == "needs-sync-down")) } +pub fn native_pending_scan(evidence: &IcloudNativeStatusEvidence) -> bool { + evidence.status_observed && evidence.pending_scan_count > 0 +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct IcloudSyncHealthReport { pub schema_version: u32, pub output_mode: String, pub observed_at_ms: u64, + /// Earliest retained observation in the current admission-blocker run. + /// + /// This is derived from the bounded local evidence journal after the current observation is + /// persisted. It is diagnostic only and never authorizes a copy, attestation, or eviction. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admission_blocked_since_ms: Option, pub provider: String, pub evidence_kind: String, pub evidence_complete: bool, @@ -325,6 +345,46 @@ fn health_evidence_fingerprint( Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect()) } +/// Recompute the fingerprint used before the added aggregate provider counters existed. +/// +/// Retained snapshots are immutable evidence. Accepting this one historical encoding keeps an +/// upgrade from silently shortening the durable stall clock while still requiring the exact old +/// digest; newly written snapshots continue to use `health_evidence_fingerprint`. +fn health_evidence_fingerprint_without_added_counters( + snapshot: &IcloudSyncHealthEvidenceSnapshot, +) -> Result { + let mut unsigned = snapshot.clone(); + unsigned.evidence_fingerprint_sha256.clear(); + let mut encoded = serde_json::to_vec(&unsigned) + .map_err(|_| "icloud-sync-health-evidence-fingerprint-encode-failed".to_string())?; + if unsigned + .file_provider_activity + .as_ref() + .is_some_and(|activity| activity.pending_indexable_count.is_none()) + { + let field = b"\"pending_indexable_count\":null,"; + let index = encoded + .windows(field.len()) + .position(|window| window == field) + .ok_or_else(|| "icloud-sync-health-evidence-legacy-field-missing".to_string())?; + encoded.drain(index..index + field.len()); + } + if unsigned + .native_status + .as_ref() + .is_some_and(|status| status.pending_scan_count == 0) + { + let field = b"\"pending_scan_count\":0,"; + let index = encoded + .windows(field.len()) + .position(|window| window == field) + .ok_or_else(|| "icloud-sync-health-evidence-legacy-field-missing".to_string())?; + encoded.drain(index..index + field.len()); + } + let digest = Sha256::digest(encoded); + Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect()) +} + /// Project a live report into the bounded, path-free durable evidence shape. pub fn health_evidence_snapshot_from_report( report: &IcloudSyncHealthReport, @@ -446,7 +506,20 @@ pub fn validate_icloud_sync_health_evidence_snapshot( } } let expected = health_evidence_fingerprint(snapshot)?; - if snapshot.evidence_fingerprint_sha256 != expected { + let legacy_expected = (snapshot + .file_provider_activity + .as_ref() + .is_some_and(|activity| activity.pending_indexable_count.is_none()) + || snapshot + .native_status + .as_ref() + .is_some_and(|status| status.pending_scan_count == 0)) + .then(|| health_evidence_fingerprint_without_added_counters(snapshot)); + let fingerprint_matches = snapshot.evidence_fingerprint_sha256 == expected + || legacy_expected + .and_then(Result::ok) + .is_some_and(|value| snapshot.evidence_fingerprint_sha256 == value); + if !fingerprint_matches { return Err("icloud-sync-health-evidence-fingerprint-invalid".into()); } Ok(()) @@ -570,6 +643,62 @@ fn prune_health_evidence(directory: &Path) -> Result<(), String> { Ok(()) } +#[cfg(not(coverage))] +fn admission_blocker_key(blockers: &[String]) -> Vec { + let mut key = blockers.to_vec(); + key.sort_unstable(); + key.dedup(); + key +} + +/// Find the earliest retained observation with the same admission blockers. +/// +/// The journal is bounded and each record is integrity-checked before it can extend the duration. +/// An invalid or unreadable historical record stops the walk rather than manufacturing a longer +/// stall interval from incomplete evidence. +#[cfg(not(coverage))] +pub fn admission_blocked_since_ms( + app_data_dir: &Path, + report: &IcloudSyncHealthReport, +) -> Option { + let current_key = admission_blocker_key(&report.new_copy_admission_blockers); + if current_key.is_empty() || report.observed_at_ms == 0 { + return None; + } + let directory = health_evidence_directory(app_data_dir).ok()?; + let mut records = std::fs::read_dir(directory) + .ok()? + .filter_map(Result::ok) + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + is_health_evidence_record_name(&name).then_some((name, entry.path())) + }) + .collect::>(); + records.sort_by(|left, right| right.0.cmp(&left.0)); + + let mut since = report.observed_at_ms; + for (_, path) in records { + let encoded = match std::fs::read(path) { + Ok(encoded) => encoded, + Err(_) => break, + }; + let snapshot = match serde_json::from_slice::(&encoded) { + Ok(snapshot) if validate_icloud_sync_health_evidence_snapshot(&snapshot).is_ok() => { + snapshot + } + _ => break, + }; + if snapshot.observed_at_ms >= report.observed_at_ms { + continue; + } + if admission_blocker_key(&snapshot.new_copy_admission_blockers) != current_key { + break; + } + since = snapshot.observed_at_ms; + } + Some(since) +} + fn system_time_ms(time: SystemTime) -> Option { time.duration_since(UNIX_EPOCH) .ok() @@ -868,6 +997,13 @@ fn parse_native_status_output( ) }) .unwrap_or((None, None, None, false)); + let pending_scan_count = output + .lines() + .filter(|line| { + let marker = line.trim(); + marker.contains("apply{[") && marker.contains("pending-scan") + }) + .count() as u64; let status_observed = client_state.is_some() || server_state.is_some() || sync_state.is_some(); let evidence_complete = container_count.is_some() && client_state.is_some() @@ -891,6 +1027,9 @@ fn parse_native_status_output( if status_observed && !evidence_complete { notices.push("icloud-native-status-summary-incomplete".into()); } + if pending_scan_count > 0 { + notices.push("icloud-native-status-pending-scan".into()); + } IcloudNativeStatusEvidence { schema_version: ICLOUD_NATIVE_STATUS_SCHEMA_VERSION, observed_at_ms, @@ -904,6 +1043,7 @@ fn parse_native_status_output( server_state, sync_state, last_sync_present, + pending_scan_count, notices, } } @@ -1010,6 +1150,16 @@ fn parse_file_provider_activity_output( .contains("excluded from sync under root") }) .count() as u64; + let pending_indexable_count = output.lines().find_map(|line| { + let marker = line.trim().strip_prefix("+ ").unwrap_or(line.trim()); + marker + .strip_prefix("pending-indexable-count:") + .and_then(|value| value.trim().parse::().ok()) + }); + let disk_import_active = output.lines().any(|line| { + let marker = line.trim().strip_prefix("+ ").unwrap_or(line.trim()); + marker.eq_ignore_ascii_case("disk import: yes") + }); let active_upload_count = output .lines() .filter(|line| line.to_ascii_lowercase().contains("upload progress:")) @@ -1055,6 +1205,12 @@ fn parse_file_provider_activity_output( if sync_excluded_root_count > 0 { notices.push("icloud-file-provider-sync-root-excluded-observed".into()); } + if pending_indexable_count.is_some_and(|count| count > 0) { + notices.push("icloud-file-provider-indexing-pending".into()); + } + if disk_import_active { + notices.push(FILE_PROVIDER_DISK_IMPORT_NOTICE.into()); + } if active_upload_count > 0 { notices.push("icloud-file-provider-active-upload".into()); } @@ -1073,6 +1229,7 @@ fn parse_file_provider_activity_output( staged_item_missing_count, sync_excluded_filename_count, sync_excluded_root_count, + pending_indexable_count, active_upload_count, active_download_count, active_upload_progress_millionths, @@ -1419,6 +1576,12 @@ fn probe_native_status(observed_at_ms: u64) -> IcloudNativeStatusEvidence { } if native_status_summary_complete(&output) { bounded_after_summary = true; + } + if bounded_after_summary + && output + .windows("pending-scan".len()) + .any(|window| window == b"pending-scan") + { kill_group(); let _ = child.kill(); let _ = child.wait(); @@ -1893,6 +2056,7 @@ fn build_report( schema_version: ICLOUD_SYNC_HEALTH_SCHEMA_VERSION, output_mode: "icloud-local-sync-health".into(), observed_at_ms, + admission_blocked_since_ms: None, provider: "icloud".into(), evidence_kind: "supplementary-local-cloud-docs-private-schema".into(), evidence_complete, @@ -1988,6 +2152,21 @@ fn attach_native_status_admission(report: &mut IcloudSyncHealthReport) { .blockers .insert(0, "icloud-native-sync-down-pending".into()); } + if report.native_status.as_ref().is_some_and(native_pending_scan) + && !report + .new_copy_admission_blockers + .iter() + .any(|blocker| blocker == "icloud-native-status-pending-scan") + { + report.sync_backlog_present = true; + report + .new_copy_admission_blockers + .push("icloud-native-status-pending-scan".into()); + report.new_copy_admission_state = "blocked".into(); + report + .blockers + .insert(0, "icloud-native-status-pending-scan".into()); + } if let Some(activity) = report.file_provider_activity.as_ref() { let no_progress = activity.no_progress_fetch_count > 0 || activity.no_progress_create_count > 0; @@ -2031,6 +2210,16 @@ fn attach_native_status_admission(report: &mut IcloudSyncHealthReport) { if activity.sync_excluded_root_count > 0 { add_blocker("icloud-file-provider-root-excluded"); } + if activity.pending_indexable_count.is_some_and(|count| count > 0) { + add_blocker("icloud-file-provider-indexing-pending"); + } + if activity + .notices + .iter() + .any(|notice| notice == FILE_PROVIDER_DISK_IMPORT_NOTICE) + { + add_blocker(FILE_PROVIDER_DISK_IMPORT_NOTICE); + } if !no_progress && !materialization_failed { if activity.active_upload_count > 0 || activity.active_download_count > 0 { add_blocker("icloud-file-provider-transfer-active"); @@ -2232,7 +2421,8 @@ mod tests { fn parses_bounded_brctl_summary_without_retaining_paths_or_item_ids() { let evidence = parse_native_status_output( "1 containers matching '*'\n\ - c{1}m.a{3}e.C{7}s[1] foreground {client:needs-sync server:full-sync|fetched-recents|ever-full-sync sync:needs-sync-up|in-sync-up|has-synced-down|0x100 last-sync:2026-08-14 01:57:54 +0000 requestID:7}\n", + c{1}m.a{3}e.C{7}s[1] foreground {client:needs-sync server:full-sync|fetched-recents|ever-full-sync sync:needs-sync-up|in-sync-up|has-synced-down|0x100 last-sync:2026-08-14 01:57:54 +0000 requestID:7}\n\ + > apply{[ pending-scan attempts:9 last:22.0m ago next:ready cleanup:37.98m ]}\n", 42, false, true, @@ -2251,6 +2441,11 @@ mod tests { Some("needs-sync-up|in-sync-up|has-synced-down|0x100") ); assert!(evidence.last_sync_present); + assert_eq!(evidence.pending_scan_count, 1); + assert!(native_pending_scan(&evidence)); + assert!(evidence + .notices + .contains(&"icloud-native-status-pending-scan".into())); assert!(evidence.timed_out); assert!(evidence.output_truncated); assert!(!serde_json::to_string(&evidence) @@ -2291,7 +2486,7 @@ mod tests { } #[test] - fn stops_native_probe_after_summary_before_detail_stream() { + fn recognizes_native_summary_before_bounded_detail_stream() { let summary = b"1 containers matching '*'\\nforeground {client:needs-sync server:full-sync sync:needs-sync-up last-sync:now}\\n"; assert!(native_status_summary_complete(summary)); assert!(!native_status_summary_complete(b"1 containers matching '*'\\n")); @@ -2372,6 +2567,38 @@ mod tests { assert!(validate_file_provider_activity_evidence(&evidence).is_ok()); } + #[test] + fn file_provider_parser_records_pending_indexable_count() { + let evidence = parse_file_provider_activity_output( + "pending-indexable-count: 12474\n", + 42, + true, + false, + false, + ); + assert_eq!(evidence.pending_indexable_count, Some(12_474)); + assert!(evidence + .notices + .contains(&"icloud-file-provider-indexing-pending".to_string())); + assert!(validate_file_provider_activity_evidence(&evidence).is_ok()); + } + + #[test] + fn file_provider_parser_records_disk_import_without_paths() { + let evidence = parse_file_provider_activity_output( + "sync engine state:\n+ disk import: yes\n", + 42, + true, + false, + false, + ); + assert!(evidence + .notices + .contains(&"icloud-file-provider-disk-import-active".to_string())); + assert!(validate_file_provider_activity_evidence(&evidence).is_ok()); + assert!(!serde_json::to_string(&evidence).unwrap().contains("sync engine state")); + } + #[test] fn file_provider_parser_records_materialization_failures_without_paths() { let evidence = parse_file_provider_activity_output( @@ -2661,6 +2888,31 @@ mod tests { ); } + #[test] + fn native_pending_scan_blocks_new_copy_admission() { + let mut report = + build_report(1, vec![], IcloudUploadQueueSummary::default(), true, true).unwrap(); + report.native_status = Some(parse_native_status_output( + "1 containers matching '*'\n\ + foreground {client:needs-sync server:full-sync sync:needs-sync-down last-sync:now}\n\ + > apply{[ pending-scan attempts:9 last:22.0m ago next:ready cleanup:37.98m ]}\n", + 1, + true, + false, + false, + )); + attach_native_status_admission(&mut report); + + assert_eq!(report.new_copy_admission_state, "blocked"); + assert!(report + .new_copy_admission_blockers + .contains(&"icloud-native-status-pending-scan".into())); + assert_eq!( + require_new_copy_admission(&report).unwrap_err(), + "icloud-native-sync-down-pending,icloud-native-status-pending-scan" + ); + } + #[test] fn native_status_timeout_blocks_new_copy_even_with_bounded_summary() { let mut report = @@ -2843,6 +3095,78 @@ mod tests { ); } + #[test] + fn health_evidence_accepts_pre_added_counter_fingerprint() { + let mut report = build_report( + 1, + vec![], + IcloudUploadQueueSummary::default(), + true, + true, + ) + .unwrap(); + report.file_provider_activity = Some(IcloudFileProviderActivityEvidence { + schema_version: ICLOUD_FILE_PROVIDER_ACTIVITY_SCHEMA_VERSION, + observed_at_ms: 1, + command_succeeded: true, + timed_out: false, + output_truncated: false, + no_progress_fetch_count: 0, + no_progress_create_count: 0, + materialization_failure_count: 0, + staged_item_missing_count: 0, + sync_excluded_filename_count: 0, + sync_excluded_root_count: 0, + pending_indexable_count: None, + active_upload_count: 0, + active_download_count: 0, + active_upload_progress_millionths: None, + active_download_progress_millionths: None, + notices: vec!["test-notice".into()], + }); + report.native_status = Some(IcloudNativeStatusEvidence { + schema_version: ICLOUD_NATIVE_STATUS_SCHEMA_VERSION, + observed_at_ms: 1, + command_succeeded: true, + timed_out: false, + output_truncated: false, + status_observed: true, + evidence_complete: true, + container_count: Some(1), + client_state: Some("ready".into()), + server_state: Some("ready".into()), + sync_state: Some("ready".into()), + last_sync_present: false, + pending_scan_count: 0, + notices: vec!["test-notice".into()], + }); + let mut snapshot = health_evidence_snapshot_from_report(&report).unwrap(); + snapshot.evidence_fingerprint_sha256 = + health_evidence_fingerprint_without_added_counters(&snapshot).unwrap(); + validate_icloud_sync_health_evidence_snapshot(&snapshot).unwrap(); + + snapshot + .file_provider_activity + .as_mut() + .unwrap() + .pending_indexable_count = Some(1); + assert_eq!( + validate_icloud_sync_health_evidence_snapshot(&snapshot).unwrap_err(), + "icloud-sync-health-evidence-fingerprint-invalid" + ); + + snapshot + .file_provider_activity + .as_mut() + .unwrap() + .pending_indexable_count = None; + snapshot.native_status.as_mut().unwrap().pending_scan_count = 1; + assert_eq!( + validate_icloud_sync_health_evidence_snapshot(&snapshot).unwrap_err(), + "icloud-sync-health-evidence-fingerprint-invalid" + ); + } + #[cfg(not(coverage))] #[test] fn health_evidence_is_create_only_and_bounded() { @@ -2876,6 +3200,46 @@ mod tests { assert!(!first.exists()); } + #[cfg(not(coverage))] + #[test] + fn admission_blocked_since_uses_only_contiguous_matching_evidence() { + let directory = tempfile::tempdir().unwrap(); + for observed_at_ms in 1..=2 { + let report = build_report( + observed_at_ms, + vec![], + parse_queue_rows(queue_output()).unwrap(), + false, + false, + ) + .unwrap(); + write_icloud_sync_health_evidence(directory.path(), &report).unwrap(); + } + + let current = build_report( + 3, + vec![], + parse_queue_rows(queue_output()).unwrap(), + false, + false, + ) + .unwrap(); + assert_eq!( + admission_blocked_since_ms(directory.path(), ¤t), + Some(1) + ); + + let mut changed = current.clone(); + changed.observed_at_ms = 4; + changed + .new_copy_admission_blockers + .push("icloud-upload-out-of-quota".into()); + assert_eq!( + admission_blocked_since_ms(directory.path(), &changed), + Some(4) + ); + } + #[test] fn health_evidence_rejects_unsafe_report_claims() { let mut report = diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad9481876..c4cdbc51f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,8 @@ compile_error!("DiskSage supports only Windows, Linux, and macOS targets."); mod dupes; #[cfg_attr(coverage, allow(dead_code))] mod commands; +#[path = "copy_headroom.rs"] +pub(crate) mod copy_headroom; #[cfg_attr(coverage, allow(dead_code))] mod generic_cleanup; #[cfg_attr(coverage, allow(dead_code))] diff --git a/src-tauri/src/naruon_cloud_copy_readiness.rs b/src-tauri/src/naruon_cloud_copy_readiness.rs index 085ed80bc..996b15676 100644 --- a/src-tauri/src/naruon_cloud_copy_readiness.rs +++ b/src-tauri/src/naruon_cloud_copy_readiness.rs @@ -14,9 +14,11 @@ use sha2::{Digest, Sha256}; use crate::cloud::{CloudPlanOptions, CloudPlanReport, CloudProvider, PreCopyEvidenceCohort}; use crate::cloud_transfer; use crate::icloud_sync_health::{ - native_sync_down_pending, native_sync_up_pending, validate_native_status_evidence, + native_pending_scan, native_sync_down_pending, native_sync_up_pending, + validate_native_status_evidence, validate_file_provider_activity_evidence, IcloudFileProviderActivityEvidence, IcloudNativeStatusEvidence, IcloudSyncHealthReport, ICLOUD_SYNC_HEALTH_SCHEMA_VERSION, + FILE_PROVIDER_DISK_IMPORT_NOTICE, }; use crate::naruon_capacity; use crate::provider_capacity::{self, CapacityEvidenceKind, CloudCapacityAssessment}; @@ -33,7 +35,7 @@ const RUNTIME_BLOCKERS: [&str; 2] = [ "provider-client-runtime-not-observed", "provider-client-runtime-evidence-unavailable", ]; -const ICLOUD_ADMISSION_BLOCKERS: [&str; 22] = [ +const ICLOUD_ADMISSION_BLOCKERS: [&str; 25] = [ "icloud-sync-health-evidence-incomplete", "icloud-upload-queue-nonempty", "icloud-upload-in-flight", @@ -45,12 +47,15 @@ const ICLOUD_ADMISSION_BLOCKERS: [&str; 22] = [ "icloud-native-status-command-timeout", "icloud-native-sync-up-pending", "icloud-native-sync-down-pending", + "icloud-native-status-pending-scan", "icloud-file-provider-no-progress", "icloud-file-provider-materialization-failed", "icloud-file-provider-item-locked", "icloud-file-provider-stalled", "icloud-file-provider-filename-excluded", "icloud-file-provider-root-excluded", + "icloud-file-provider-indexing-pending", + "icloud-file-provider-disk-import-active", "icloud-file-provider-transfer-active", "icloud-file-provider-dump-timeout", "icloud-file-provider-dump-output-truncated", @@ -320,6 +325,9 @@ fn expected_icloud_admission_blockers(report: &IcloudSyncHealthReport) -> Vec 0 || activity.no_progress_create_count > 0; @@ -351,6 +359,16 @@ fn expected_icloud_admission_blockers(report: &IcloudSyncHealthReport) -> Vec 0 { blockers.push("icloud-file-provider-root-excluded".into()); } + if activity.pending_indexable_count.is_some_and(|count| count > 0) { + blockers.push("icloud-file-provider-indexing-pending".into()); + } + if activity + .notices + .iter() + .any(|notice| notice == "icloud-file-provider-disk-import-active") + { + blockers.push("icloud-file-provider-disk-import-active".into()); + } if !no_progress && !materialization_failed && (activity.active_upload_count > 0 || activity.active_download_count > 0) { @@ -1192,6 +1210,9 @@ fn validate_icloud_admission_summary( { expected.push("icloud-native-sync-down-pending".to_string()); } + if summary.native_status.as_ref().is_some_and(native_pending_scan) { + expected.push("icloud-native-status-pending-scan".to_string()); + } if let Some(activity) = summary.file_provider_activity.as_ref() { let no_progress = activity.no_progress_fetch_count > 0 || activity.no_progress_create_count > 0; @@ -1223,6 +1244,16 @@ fn validate_icloud_admission_summary( if activity.sync_excluded_root_count > 0 { expected.push("icloud-file-provider-root-excluded".to_string()); } + if activity.pending_indexable_count.is_some_and(|count| count > 0) { + expected.push("icloud-file-provider-indexing-pending".to_string()); + } + if activity + .notices + .iter() + .any(|notice| notice == FILE_PROVIDER_DISK_IMPORT_NOTICE) + { + expected.push(FILE_PROVIDER_DISK_IMPORT_NOTICE.to_string()); + } if !no_progress && !materialization_failed && (activity.active_upload_count > 0 || activity.active_download_count > 0) { @@ -1440,6 +1471,7 @@ mod tests { schema_version: ICLOUD_SYNC_HEALTH_SCHEMA_VERSION, output_mode: "icloud-local-sync-health".into(), observed_at_ms: 30, + admission_blocked_since_ms: None, provider: "icloud".into(), evidence_kind: "supplementary-local-cloud-docs-private-schema".into(), evidence_complete: true, @@ -1489,6 +1521,7 @@ mod tests { server_state: Some("full-sync".into()), sync_state: Some("needs-sync-up".into()), last_sync_present: true, + pending_scan_count: 0, notices: vec!["icloud-native-status-summary-observed".into()], } } @@ -1591,6 +1624,8 @@ mod tests { schema_version: provider_global_sync::PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION, provider: CloudProvider::Onedrive, evidence_kind: "fileproviderctl-global-dump".into(), + observed_at_ms: 1, + admission_blocked_since_ms: None, evidence_complete: true, state: ProviderGlobalSyncState::Pending, upload_progress_present: true, @@ -1741,15 +1776,19 @@ mod tests { let report = report(CloudProvider::Icloud); let runtime = assess_provider_client_runtime(CloudProvider::Icloud, None, 25); let mut health = icloud_health(false); - health.native_status = Some(native_sync_up_status()); + let mut native = native_sync_up_status(); + native.pending_scan_count = 1; + health.native_status = Some(native); health.new_copy_admission_state = "blocked".into(); health.new_copy_admission_blockers = vec![ "icloud-native-status-command-timeout".into(), "icloud-native-sync-up-pending".into(), + "icloud-native-status-pending-scan".into(), ]; health.blockers = vec![ "icloud-native-status-command-timeout".into(), "icloud-native-sync-up-pending".into(), + "icloud-native-status-pending-scan".into(), ]; let envelope = @@ -1760,7 +1799,8 @@ mod tests { admission.blockers, vec![ "icloud-native-status-command-timeout", - "icloud-native-sync-up-pending" + "icloud-native-sync-up-pending", + "icloud-native-status-pending-scan" ] ); assert_eq!( @@ -1956,7 +1996,7 @@ mod tests { let mut forged_icloud_blocker = export_naruon_cloud_copy_readiness(&onedrive_report, &runtime, None).unwrap(); forged_icloud_blocker.candidate_blocker_counts.insert( - "icloud-upload-queue-nonempty".into(), + "icloud-file-provider-indexing-pending".into(), CountBytes { count: forged_icloud_blocker.candidate_count, bytes: forged_icloud_blocker.candidate_bytes, diff --git a/src-tauri/src/provider_evidence.rs b/src-tauri/src/provider_evidence.rs index 705772121..e8ee10db2 100644 --- a/src-tauri/src/provider_evidence.rs +++ b/src-tauri/src/provider_evidence.rs @@ -216,9 +216,9 @@ fn prune_receipt_evidence_history( } records.sort_by(|left, right| (left.0, left.1.as_str()).cmp(&(right.0, right.1.as_str()))); let prune_count = records.len() - MAX_PROVIDER_EVIDENCE_RECORDS_PER_RECEIPT; - for (_, _, path) in records + for (_, _record_id, path) in records .into_iter() - .filter(|(_, record_id, _)| record_id.as_str() != protected_record_id) + .filter(|(_, record_id, _)| record_id != protected_record_id) .take(prune_count) { remove_retained_evidence_file(&path)?; @@ -233,9 +233,9 @@ fn prune_receipt_evidence_history( /// Persist the full provider claim before it is used to authorize source eviction. /// /// The file is create-only, read-only, fsynced, and named by the receipt, observation time, and -/// integrity digest. Existing evidence is never overwritten. Repeated attestations retain a -/// bounded per-receipt history while preserving the just-written immutable record even if the -/// local clock moves backwards. +/// integrity digest. Existing evidence is never overwritten. Repeated attestations retain the +/// newest bounded per-receipt history so background reconciliation cannot grow storage forever; +/// the just-written protected record is retained even if the system clock regresses. #[cfg(not(coverage))] pub fn write_immutable_sync_evidence( directory: &Path, diff --git a/src-tauri/src/provider_global_sync.rs b/src-tauri/src/provider_global_sync.rs index a4f4e45ce..5c5398793 100644 --- a/src-tauri/src/provider_global_sync.rs +++ b/src-tauri/src/provider_global_sync.rs @@ -7,6 +7,8 @@ use crate::cloud::CloudProvider; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; // macOS provider dumps include bounded item summaries even with --limit-dump-size. Keep enough // room for real OneDrive/Google Drive dumps while retaining a hard memory ceiling. @@ -41,6 +43,11 @@ pub struct ProviderGlobalSyncReport { pub schema_version: u32, pub provider: CloudProvider, pub evidence_kind: String, + #[serde(default)] + pub observed_at_ms: u64, + /// Earliest retained observation in the current provider admission-blocker run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admission_blocked_since_ms: Option, pub evidence_complete: bool, pub state: ProviderGlobalSyncState, pub upload_progress_present: bool, @@ -167,14 +174,15 @@ pub fn parse_dump( has_item_not_found |= marker_lower.contains("code=-1005") || marker_lower.contains("itemnotfound") || marker.contains("파일이 존재하지 않습니다"); - has_local_disk_full |= contains_bounded_numeric_marker(&marker_lower, "odresult_errno ", "28") - || contains_bounded_numeric_marker(&marker_lower, "errno ", "28") - || marker_lower.contains("enospc") - || contains_bounded_numeric_marker(&marker_lower, "code=", "28") - || contains_bounded_numeric_marker(&marker_lower, "code ", "28") - || contains_bounded_numeric_marker(&marker_lower, "osstatus ", "-34") - || marker_lower.contains("no space left on device") - || marker_lower.contains("disk full"); + has_local_disk_full |= + contains_bounded_numeric_marker(&marker_lower, "odresult_errno ", "28") + || contains_bounded_numeric_marker(&marker_lower, "errno ", "28") + || marker_lower.contains("enospc") + || contains_bounded_numeric_marker(&marker_lower, "code=", "28") + || contains_bounded_numeric_marker(&marker_lower, "code ", "28") + || contains_bounded_numeric_marker(&marker_lower, "osstatus -", "34") + || marker_lower.contains("no space left on device") + || marker_lower.contains("disk full"); if has_filename_too_long || has_temporarily_disconnected || has_server_unreachable @@ -252,6 +260,8 @@ pub fn parse_dump( schema_version: PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION, provider, evidence_kind: "fileproviderctl-global-dump".into(), + observed_at_ms: 0, + admission_blocked_since_ms: None, evidence_complete: !probe_timed_out, state, upload_progress_present, @@ -382,7 +392,9 @@ pub fn inspect_new_copy_admission( provider: CloudProvider, ) -> Result { let output = run_dump(provider)?; - parse_dump(provider, &output) + let mut report = parse_dump(provider, &output)?; + report.observed_at_ms = system_time_ms(); + Ok(report) } #[cfg(not(target_os = "macos"))] @@ -401,6 +413,287 @@ fn report_identity_is_valid(report: &ProviderGlobalSyncReport) -> bool { && provider_identifier(report.provider).is_some() } +pub const PROVIDER_GLOBAL_SYNC_EVIDENCE_SCHEMA_VERSION: u32 = 1; +pub const PROVIDER_GLOBAL_SYNC_EVIDENCE_DIRECTORY: &str = "provider-global-sync-evidence"; +const MAX_PERSISTED_PROVIDER_GLOBAL_SYNC_SNAPSHOTS: usize = 128; +const MAX_PERSISTED_PROVIDER_GLOBAL_SYNC_SNAPSHOT_BYTES: usize = 64 * 1024; + +/// Path-free provider-global evidence retained only to measure a blocker across restarts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderGlobalSyncEvidenceSnapshot { + pub schema_version: u32, + pub observed_at_ms: u64, + pub provider: CloudProvider, + pub evidence_complete: bool, + pub state: ProviderGlobalSyncState, + pub upload_progress_present: bool, + pub download_progress_present: bool, + pub pending_indexable_count: Option, + pub blockers: Vec, + pub evidence_fingerprint_sha256: String, +} + +fn system_time_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + .unwrap_or(0) +} + +fn provider_global_sync_blocker_key(report: &ProviderGlobalSyncReport) -> String { + let mut blockers = report.blockers.clone(); + blockers.sort_unstable(); + blockers.dedup(); + format!( + "{}|{}|{}|{}|{}|{}", + report.provider.as_str(), + report.state.as_str(), + report.upload_progress_present, + report.download_progress_present, + report + .pending_indexable_count + .is_some_and(|count| count > 0), + blockers.join(",") + ) +} + +fn provider_global_sync_snapshot_key(snapshot: &ProviderGlobalSyncEvidenceSnapshot) -> String { + let mut blockers = snapshot.blockers.clone(); + blockers.sort_unstable(); + blockers.dedup(); + format!( + "{}|{}|{}|{}|{}|{}", + snapshot.provider.as_str(), + snapshot.state.as_str(), + snapshot.upload_progress_present, + snapshot.download_progress_present, + snapshot + .pending_indexable_count + .is_some_and(|count| count > 0), + blockers.join(",") + ) +} + +fn provider_global_sync_fingerprint( + snapshot: &ProviderGlobalSyncEvidenceSnapshot, +) -> Result { + let mut unsigned = snapshot.clone(); + unsigned.evidence_fingerprint_sha256.clear(); + let encoded = serde_json::to_vec(&unsigned) + .map_err(|_| "provider-global-sync-evidence-fingerprint-encode-failed".to_string())?; + let digest = Sha256::digest(encoded); + Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +pub fn provider_global_sync_evidence_snapshot_from_report( + report: &ProviderGlobalSyncReport, +) -> Result { + if !report_identity_is_valid(report) + || report.observed_at_ms == 0 + || report + .blockers + .iter() + .any(|blocker| !is_stable_provider_blocker(blocker)) + { + return Err("provider-global-sync-evidence-claim-invalid".into()); + } + let mut snapshot = ProviderGlobalSyncEvidenceSnapshot { + schema_version: PROVIDER_GLOBAL_SYNC_EVIDENCE_SCHEMA_VERSION, + observed_at_ms: report.observed_at_ms, + provider: report.provider, + evidence_complete: report.evidence_complete, + state: report.state, + upload_progress_present: report.upload_progress_present, + download_progress_present: report.download_progress_present, + pending_indexable_count: report.pending_indexable_count, + blockers: report.blockers.clone(), + evidence_fingerprint_sha256: String::new(), + }; + snapshot.evidence_fingerprint_sha256 = provider_global_sync_fingerprint(&snapshot)?; + Ok(snapshot) +} + +pub fn validate_provider_global_sync_evidence_snapshot( + snapshot: &ProviderGlobalSyncEvidenceSnapshot, +) -> Result<(), String> { + if snapshot.schema_version != PROVIDER_GLOBAL_SYNC_EVIDENCE_SCHEMA_VERSION + || snapshot.observed_at_ms == 0 + || provider_identifier(snapshot.provider).is_none() + || snapshot + .blockers + .iter() + .any(|blocker| !is_stable_provider_blocker(blocker)) + { + return Err("provider-global-sync-evidence-shape-invalid".into()); + } + let expected = provider_global_sync_fingerprint(snapshot)?; + if snapshot.evidence_fingerprint_sha256 != expected { + return Err("provider-global-sync-evidence-fingerprint-invalid".into()); + } + Ok(()) +} + +#[cfg(not(coverage))] +fn provider_global_sync_evidence_directory(app_data_dir: &Path) -> Result { + if !app_data_dir.is_absolute() + || app_data_dir + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("provider-global-sync-evidence-parent-invalid".into()); + } + std::fs::create_dir_all(app_data_dir) + .map_err(|_| "provider-global-sync-evidence-parent-create-failed".to_string())?; + let parent = std::fs::symlink_metadata(app_data_dir) + .map_err(|_| "provider-global-sync-evidence-parent-unavailable".to_string())?; + if parent.file_type().is_symlink() || !parent.is_dir() { + return Err("provider-global-sync-evidence-parent-unsafe".into()); + } + let directory = app_data_dir.join(PROVIDER_GLOBAL_SYNC_EVIDENCE_DIRECTORY); + std::fs::create_dir_all(&directory) + .map_err(|_| "provider-global-sync-evidence-directory-create-failed".to_string())?; + let metadata = std::fs::symlink_metadata(&directory) + .map_err(|_| "provider-global-sync-evidence-directory-unavailable".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("provider-global-sync-evidence-directory-unsafe".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).map_err( + |_| "provider-global-sync-evidence-directory-permissions-failed".to_string(), + )?; + } + Ok(directory) +} + +#[cfg(not(coverage))] +fn prune_provider_global_sync_evidence(directory: &Path) -> Result<(), String> { + let mut records = std::fs::read_dir(directory) + .map_err(|_| "provider-global-sync-evidence-directory-read-failed".to_string())? + .filter_map(Result::ok) + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + let (timestamp, fingerprint) = name.strip_suffix(".json")?.split_once('-')?; + (timestamp.len() == 20 + && timestamp.bytes().all(|byte| byte.is_ascii_digit()) + && fingerprint.len() == 64 + && fingerprint + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + .then_some((name, entry.path())) + }) + .collect::>(); + records.sort_by(|left, right| left.0.cmp(&right.0)); + while records.len() > MAX_PERSISTED_PROVIDER_GLOBAL_SYNC_SNAPSHOTS { + let (_, path) = records.remove(0); + std::fs::remove_file(path) + .map_err(|_| "provider-global-sync-evidence-retention-failed".to_string())?; + } + Ok(()) +} + +/// Persist a bounded, path-free provider observation; it never mutates the cloud root. +#[cfg(not(coverage))] +pub fn write_provider_global_sync_evidence( + app_data_dir: &Path, + report: &ProviderGlobalSyncReport, +) -> Result { + use std::io::Write; + let snapshot = provider_global_sync_evidence_snapshot_from_report(report)?; + validate_provider_global_sync_evidence_snapshot(&snapshot)?; + let directory = provider_global_sync_evidence_directory(app_data_dir)?; + let path = directory.join(format!( + "{:020}-{}.json", + snapshot.observed_at_ms, snapshot.evidence_fingerprint_sha256 + )); + let encoded = serde_json::to_vec_pretty(&snapshot) + .map_err(|_| "provider-global-sync-evidence-encode-failed".to_string())?; + if encoded.len() > MAX_PERSISTED_PROVIDER_GLOBAL_SYNC_SNAPSHOT_BYTES { + return Err("provider-global-sync-evidence-too-large".into()); + } + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o400); + } + let mut file = options + .open(&path) + .map_err(|_| "provider-global-sync-evidence-create-failed".to_string())?; + let result = file + .write_all(&encoded) + .and_then(|_| file.sync_all()) + .map_err(|_| "provider-global-sync-evidence-write-failed".to_string()); + if let Err(error) = result { + drop(file); + let _ = std::fs::remove_file(&path); + return Err(error); + } + #[cfg(unix)] + std::fs::File::open(&directory) + .and_then(|directory| directory.sync_all()) + .map_err(|_| "provider-global-sync-evidence-directory-sync-failed".to_string())?; + prune_provider_global_sync_evidence(&directory)?; + Ok(path) +} + +/// Return the earliest retained observation with the same provider blocker fingerprint. +#[cfg(not(coverage))] +pub fn provider_global_sync_blocked_since_ms( + app_data_dir: &Path, + report: &ProviderGlobalSyncReport, +) -> Option { + if report.blockers.is_empty() || report.observed_at_ms == 0 { + return None; + } + let current_key = provider_global_sync_blocker_key(report); + let directory = provider_global_sync_evidence_directory(app_data_dir).ok()?; + let mut records = std::fs::read_dir(directory) + .ok()? + .filter_map(Result::ok) + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + name.strip_suffix(".json")?.split_once('-')?; + Some((name, entry.path())) + }) + .collect::>(); + records.sort_by(|left, right| right.0.cmp(&left.0)); + let mut since = report.observed_at_ms; + for (_, path) in records { + let encoded = match std::fs::read(path) { + Ok(encoded) => encoded, + Err(_) => break, + }; + let snapshot = match serde_json::from_slice::(&encoded) + { + Ok(snapshot) => snapshot, + Err(_) => break, + }; + if validate_provider_global_sync_evidence_snapshot(&snapshot).is_err() { + break; + } + if snapshot.provider != report.provider { + continue; + } + if !snapshot.evidence_complete { + break; + } + if snapshot.observed_at_ms >= report.observed_at_ms { + continue; + } + if provider_global_sync_snapshot_key(&snapshot) != current_key { + break; + } + since = snapshot.observed_at_ms; + } + Some(since) +} + fn report_has_pending_aggregate_evidence(report: &ProviderGlobalSyncReport) -> bool { report.upload_progress_present || report.download_progress_present @@ -723,4 +1016,101 @@ sync engine state: assert!(parse_dump(CloudProvider::Onedrive, "sync engine state:").is_err()); assert!(parse_dump(CloudProvider::Icloud, QUIET_DUMP).is_err()); } + + #[test] + fn provider_blocker_onset_survives_restart_without_retaining_paths() { + let directory = tempfile::tempdir().unwrap(); + let mut first = parse_dump(CloudProvider::GoogleDrive, ACTIVE_DUMP).unwrap(); + first.observed_at_ms = 1_000; + write_provider_global_sync_evidence(directory.path(), &first).unwrap(); + + let mut second = first.clone(); + second.observed_at_ms = 2_000; + write_provider_global_sync_evidence(directory.path(), &second).unwrap(); + + assert_eq!( + provider_global_sync_blocked_since_ms(directory.path(), &second), + Some(1_000) + ); + let encoded = std::fs::read_dir( + directory + .path() + .join(PROVIDER_GLOBAL_SYNC_EVIDENCE_DIRECTORY), + ) + .unwrap() + .next() + .unwrap() + .unwrap(); + let contents = std::fs::read_to_string(encoded.path()).unwrap(); + assert!(!contents.contains("/Users/")); + assert!(!contents.contains("fileproviderctl")); + } + + #[test] + fn provider_blocker_onset_ignores_interleaved_provider_evidence() { + let directory = tempfile::tempdir().unwrap(); + let mut google = parse_dump(CloudProvider::GoogleDrive, ACTIVE_DUMP).unwrap(); + google.observed_at_ms = 1_000; + write_provider_global_sync_evidence(directory.path(), &google).unwrap(); + + let onedrive_dump = ACTIVE_DUMP.replace( + "com.google.drivefs.fpext", + "com.microsoft.OneDrive.FileProvider", + ); + let mut onedrive = parse_dump(CloudProvider::Onedrive, &onedrive_dump).unwrap(); + onedrive.observed_at_ms = 1_500; + write_provider_global_sync_evidence(directory.path(), &onedrive).unwrap(); + + let mut later_google = google.clone(); + later_google.observed_at_ms = 2_000; + assert_eq!( + provider_global_sync_blocked_since_ms(directory.path(), &later_google), + Some(1_000) + ); + } + + #[test] + fn malformed_older_provider_evidence_preserves_newer_onset() { + let directory = tempfile::tempdir().unwrap(); + let mut report = parse_dump(CloudProvider::GoogleDrive, ACTIVE_DUMP).unwrap(); + report.observed_at_ms = 1_500; + write_provider_global_sync_evidence(directory.path(), &report).unwrap(); + let malformed_path = directory + .path() + .join(PROVIDER_GLOBAL_SYNC_EVIDENCE_DIRECTORY) + .join(format!("{:020}-malformed.json", 1_000)); + std::fs::write(malformed_path, b"not-json").unwrap(); + + let later = ProviderGlobalSyncReport { + observed_at_ms: 2_000, + ..report + }; + assert_eq!( + provider_global_sync_blocked_since_ms(directory.path(), &later), + Some(1_500) + ); + } + + #[test] + fn tampered_provider_evidence_cannot_extend_blocker_duration() { + let directory = tempfile::tempdir().unwrap(); + let mut report = parse_dump(CloudProvider::GoogleDrive, ACTIVE_DUMP).unwrap(); + report.observed_at_ms = 1_000; + write_provider_global_sync_evidence(directory.path(), &report).unwrap(); + let mut snapshot = provider_global_sync_evidence_snapshot_from_report(&report).unwrap(); + snapshot.observed_at_ms = 1; + let tampered_path = directory + .path() + .join(PROVIDER_GLOBAL_SYNC_EVIDENCE_DIRECTORY) + .join(format!("{:020}-{}.json", 1, "0".repeat(64))); + std::fs::write(tampered_path, serde_json::to_vec(&snapshot).unwrap()).unwrap(); + let later = ProviderGlobalSyncReport { + observed_at_ms: 2_000, + ..report + }; + assert_eq!( + provider_global_sync_blocked_since_ms(directory.path(), &later), + Some(1_000) + ); + } } diff --git a/src-tauri/src/provider_sync.rs b/src-tauri/src/provider_sync.rs index a9dc2c466..141be7d02 100644 --- a/src-tauri/src/provider_sync.rs +++ b/src-tauri/src/provider_sync.rs @@ -1326,6 +1326,21 @@ mod tests { ); } + #[test] + fn local_current_but_not_uploaded_is_pending_upload_evidence() { + let output = uploaded_file_provider_output().replace("isUploaded = 1", "isUploaded = 0"); + let snapshot = parse_file_providerctl_snapshot(&output, 42, "content-hash").unwrap(); + assert!(snapshot.is_local_current()); + assert!(!snapshot.is_sync_complete()); + + let evidence = + evidence_from_file_provider_snapshot(&receipt(CloudProvider::Onedrive), &snapshot, 30) + .unwrap(); + assert!(!evidence.sync_complete); + assert_eq!(evidence.sync_state, ProviderSyncState::PendingUpload); + assert_eq!(incomplete_sync_blocker(evidence.sync_complete), Some("provider-sync-incomplete")); + } + #[test] fn trashed_file_provider_item_remains_incomplete() { let output = uploaded_file_provider_output().replace("isTrashed = 0", "isTrashed = 1"); diff --git a/src-tauri/tests/cloud_copy_headroom_destination_contract.rs b/src-tauri/tests/cloud_copy_headroom_destination_contract.rs index 5049170ed..434e1ee96 100644 --- a/src-tauri/tests/cloud_copy_headroom_destination_contract.rs +++ b/src-tauri/tests/cloud_copy_headroom_destination_contract.rs @@ -24,3 +24,20 @@ fn native_copy_headroom_is_bound_to_the_destination_staging_volume() { "source-volume free space must not authorize or veto destination staging" ); } + +#[test] +fn cloud_plan_preview_headroom_uses_destination_staging_volume() { + let cloud = include_str!("../src/cloud.rs"); + let start = cloud + .find("let mut destination_headroom_insufficient") + .expect("cloud preview must evaluate destination headroom"); + let tail = &cloud[start..]; + let end = tail + .find("\n if !snapshot.source_scan_complete") + .expect("destination preview gate must remain before source-scan notices"); + let helper = &tail[..end]; + + assert!(helper.contains("require_destination_copy_headroom")); + assert!(helper.contains("candidate.dst")); + assert!(!helper.contains("candidate.src")); +} diff --git a/src-tauri/tests/cloud_plan_destination_headroom_runtime.rs b/src-tauri/tests/cloud_plan_destination_headroom_runtime.rs new file mode 100644 index 000000000..bf6dfafba --- /dev/null +++ b/src-tauri/tests/cloud_plan_destination_headroom_runtime.rs @@ -0,0 +1,183 @@ +use disksage_lib::cloud::{ + plan_cloud_archive, CloudAccountScope, CloudPlanOptions, CloudProvider, CloudRoot, ContentMetadata, + FileFact, production_year_month, system_now_ms, +}; +use disksage_lib::cloud_plan_view::normalize_native_copy_headroom_notices; + +#[cfg(unix)] +#[test] +fn cloud_plan_preview_uses_destination_ancestor_authority_at_runtime() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().unwrap(); + let source_root = fixture.path().join("source"); + let cloud_root = fixture.path().join("cloud"); + let redirected_archive = fixture.path().join("redirected-archive"); + std::fs::create_dir(&source_root).unwrap(); + std::fs::create_dir(&cloud_root).unwrap(); + std::fs::create_dir(&redirected_archive).unwrap(); + + let source_file = source_root.join("report.pdf"); + std::fs::write(&source_file, b"report").unwrap(); + let source_metadata = std::fs::metadata(&source_file).unwrap(); + let modified_ms = source_metadata + .modified() + .unwrap() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let observed_at_ms = system_now_ms(); + + // The final candidate itself does not exist, so ordinary destination-exists checks do not + // block it. The nearest existing staging ancestor is nevertheless a symlink and must not + // become capacity authority for a native-copy preview. + symlink( + &redirected_archive, + cloud_root.join("DiskSage Archive"), + ) + .unwrap(); + + let file = FileFact { + path: source_file, + bytes: source_metadata.len(), + created_ms: observed_at_ms, + modified_ms, + content_metadata: ContentMetadata::default(), + }; + let root = CloudRoot { + id: "google-drive:test".into(), + provider: CloudProvider::GoogleDrive, + account_scope: CloudAccountScope::Personal, + label: "Google Drive".into(), + path: cloud_root.to_string_lossy().into_owned(), + readable: true, + access_issue: None, + }; + + let mut report = plan_cloud_archive( + &[file], + &source_root, + &root, + observed_at_ms, + CloudPlanOptions { + min_size_bytes: 1, + min_age_days: 0, + limit: 10, + }, + ); + normalize_native_copy_headroom_notices(&mut report); + + assert_eq!(report.candidates.len(), 1); + assert_eq!( + report.candidates[0].blocked_reason.as_deref(), + Some("local-volume-headroom-destination-parent-unsafe") + ); + assert!( + report + .notices + .iter() + .any(|notice| notice == "local-volume-headroom-unverified"), + "preview must reject an unsafe destination/staging capacity authority even when the source volume is healthy", + ); + assert!( + report.local_volume.is_some(), + "source-volume pressure remains independent diagnostics rather than staging authority", + ); + assert!( + !redirected_archive.join("documents").join("report.pdf").exists(), + "dry-run planning must not materialize the redirected destination", + ); +} + +#[cfg(unix)] +#[test] +fn one_unverified_candidate_does_not_blanket_block_candidates_with_verified_headroom() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().unwrap(); + let source_root = fixture.path().join("source"); + let cloud_root = fixture.path().join("cloud"); + let archive_root = cloud_root.join("DiskSage Archive"); + let redirected_documents = fixture.path().join("redirected-documents"); + std::fs::create_dir(&source_root).unwrap(); + std::fs::create_dir(&cloud_root).unwrap(); + std::fs::create_dir(&archive_root).unwrap(); + std::fs::create_dir(&redirected_documents).unwrap(); + + let observed_at_ms = system_now_ms(); + let (year, month) = production_year_month(observed_at_ms); + let archive_month = archive_root + .join(format!("{year:04}")) + .join(format!("{month:02}")); + std::fs::create_dir_all(&archive_month).unwrap(); + symlink(&redirected_documents, archive_month.join("documents")).unwrap(); + let mut facts = Vec::new(); + for (name, bytes) in [("report.pdf", b"report".as_slice()), ("clip.mp4", b"clip".as_slice())] { + let path = source_root.join(name); + std::fs::write(&path, bytes).unwrap(); + let metadata = std::fs::metadata(&path).unwrap(); + let modified_ms = metadata + .modified() + .unwrap() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + facts.push(FileFact { + path, + bytes: metadata.len(), + created_ms: observed_at_ms, + modified_ms, + content_metadata: ContentMetadata::default(), + }); + } + + let root = CloudRoot { + id: "google-drive:test".into(), + provider: CloudProvider::GoogleDrive, + account_scope: CloudAccountScope::Personal, + label: "Google Drive".into(), + path: cloud_root.to_string_lossy().into_owned(), + readable: true, + access_issue: None, + }; + let mut report = plan_cloud_archive( + &facts, + &source_root, + &root, + observed_at_ms, + CloudPlanOptions { + min_size_bytes: 1, + min_age_days: 0, + limit: 10, + }, + ); + normalize_native_copy_headroom_notices(&mut report); + + assert_eq!(report.candidates.len(), 2); + assert!(report.candidates.iter().all(|candidate| candidate.blocked_reason.is_none())); + assert!( + report + .notices + .iter() + .any(|notice| notice == "local-volume-headroom-partial"), + "mixed per-candidate headroom results need a non-blocking plan diagnostic", + ); + assert!( + !report + .notices + .iter() + .any(|notice| notice == "local-volume-headroom-unverified"), + "one unsafe destination ancestor must not disable candidates whose own staging headroom is verified", + ); + assert!( + !report + .notices + .iter() + .any(|notice| notice == "local-volume-headroom-insufficient"), + "plan-wide native-copy blockers are reserved for plans where no candidate has verified headroom", + ); + assert!( + !redirected_documents.join("report.pdf").exists(), + "dry-run planning must not materialize the redirected candidate", + ); +} diff --git a/src-tauri/tests/folded_received_header_plan_regression.rs b/src-tauri/tests/folded_received_header_plan_regression.rs new file mode 100644 index 000000000..3d516511c --- /dev/null +++ b/src-tauri/tests/folded_received_header_plan_regression.rs @@ -0,0 +1,63 @@ +use disksage_lib::cloud::{ + plan_cloud_archive, CloudAccountScope, CloudPlanOptions, CloudProvider, CloudRoot, ContentMetadata, + FileFact, +}; + +#[test] +fn folded_received_header_at_end_of_block_is_safe_through_public_plan() { + let fixture = tempfile::tempdir().unwrap(); + let source_root = fixture.path().join("source"); + let cloud_root = fixture.path().join("cloud"); + std::fs::create_dir(&source_root).unwrap(); + std::fs::create_dir(&cloud_root).unwrap(); + + let message = concat!( + "Date: Mon, 17 Aug 2026 12:00:00 +0000\r\n", + "Subject: Folded Received regression\r\n", + "Received: from relay.example\r\n", + "\tby mx.example with ESMTP\r\n", + "\r\n", + "body is deliberately outside the bounded metadata parser\r\n", + ); + let message_path = source_root.join("folded-received.eml"); + std::fs::write(&message_path, message.as_bytes()).unwrap(); + + let report = plan_cloud_archive( + &[FileFact { + path: message_path, + bytes: message.len() as u64, + created_ms: 1, + modified_ms: 1, + content_metadata: ContentMetadata::default(), + }], + &source_root, + &CloudRoot { + id: "google-drive:test".into(), + provider: CloudProvider::GoogleDrive, + account_scope: CloudAccountScope::Personal, + label: "Google Drive".into(), + path: cloud_root.to_string_lossy().into_owned(), + readable: true, + access_issue: None, + }, + 86_400_001, + CloudPlanOptions { + min_size_bytes: 1, + min_age_days: 0, + limit: 10, + }, + ); + + assert_eq!(report.candidates.len(), 1); + let candidate = &report.candidates[0]; + assert_eq!(candidate.content_title.as_deref(), Some("Folded Received regression")); + assert!(candidate.metadata_evidence.iter().any(|evidence| { + evidence.field == "email-header-bytes-inspected" + && evidence.source == "local:metadata-probe:bounded-rfc5322-header" + })); + assert!(candidate.metadata_evidence.iter().any(|evidence| { + evidence.field == "email-body-inspected" + && evidence.value == "false" + && evidence.source == "local:metadata-probe:bounded-rfc5322-header" + })); +} diff --git a/src-tauri/tests/naruon_active_fileprovider_transfer.rs b/src-tauri/tests/naruon_active_fileprovider_transfer.rs index f7c1d4244..ed3b6f504 100644 --- a/src-tauri/tests/naruon_active_fileprovider_transfer.rs +++ b/src-tauri/tests/naruon_active_fileprovider_transfer.rs @@ -50,10 +50,12 @@ fn icloud_report() -> CloudPlanReport { fn active_transfer_health() -> IcloudSyncHealthReport { let blocker = "icloud-file-provider-transfer-active".to_string(); + let disk_import_blocker = "icloud-file-provider-disk-import-active".to_string(); IcloudSyncHealthReport { schema_version: ICLOUD_SYNC_HEALTH_SCHEMA_VERSION, output_mode: "icloud-local-sync-health".into(), observed_at_ms: 30, + admission_blocked_since_ms: None, provider: "icloud".into(), evidence_kind: "supplementary-local-cloud-docs-private-schema".into(), evidence_complete: true, @@ -81,16 +83,20 @@ fn active_transfer_health() -> IcloudSyncHealthReport { staged_item_missing_count: 0, sync_excluded_filename_count: 0, sync_excluded_root_count: 0, + pending_indexable_count: None, active_upload_count: 1, active_download_count: 0, active_upload_progress_millionths: Some(500_000), active_download_progress_millionths: None, - notices: vec!["icloud-file-provider-dump-read-only".into()], + notices: vec![ + "icloud-file-provider-dump-read-only".into(), + disk_import_blocker.clone(), + ], }), sync_backlog_present: true, new_copy_admission_state: "blocked".into(), - new_copy_admission_blockers: vec![blocker.clone()], - blockers: vec![blocker], + new_copy_admission_blockers: vec![disk_import_blocker.clone(), blocker.clone()], + blockers: vec![disk_import_blocker, blocker], notices: Vec::new(), paths_redacted: true, user_filenames_read: false, @@ -121,5 +127,9 @@ fn active_fileprovider_transfer_exports_blocked_readiness() { .blockers .iter() .any(|blocker| blocker == "icloud-file-provider-transfer-active")); + assert!(admission + .blockers + .iter() + .any(|blocker| blocker == "icloud-file-provider-disk-import-active")); assert!(validate_naruon_cloud_copy_readiness(&envelope).is_ok()); } diff --git a/src-tauri/tests/naruon_locked_fileprovider_item.rs b/src-tauri/tests/naruon_locked_fileprovider_item.rs index ff3a0d4c3..9c649552c 100644 --- a/src-tauri/tests/naruon_locked_fileprovider_item.rs +++ b/src-tauri/tests/naruon_locked_fileprovider_item.rs @@ -57,6 +57,7 @@ fn locked_item_health() -> IcloudSyncHealthReport { schema_version: ICLOUD_SYNC_HEALTH_SCHEMA_VERSION, output_mode: "icloud-local-sync-health".into(), observed_at_ms: 30, + admission_blocked_since_ms: None, provider: "icloud".into(), evidence_kind: "supplementary-local-cloud-docs-private-schema".into(), evidence_complete: true, @@ -84,6 +85,7 @@ fn locked_item_health() -> IcloudSyncHealthReport { staged_item_missing_count: 0, sync_excluded_filename_count: 0, sync_excluded_root_count: 0, + pending_indexable_count: None, active_upload_count: 0, active_download_count: 0, active_upload_progress_millionths: None, diff --git a/src-tauri/tests/naruon_readiness_global_sync_identity.rs b/src-tauri/tests/naruon_readiness_global_sync_identity.rs index 4840b0a76..790edd94c 100644 --- a/src-tauri/tests/naruon_readiness_global_sync_identity.rs +++ b/src-tauri/tests/naruon_readiness_global_sync_identity.rs @@ -56,6 +56,8 @@ fn canonical_clear_report() -> ProviderGlobalSyncReport { schema_version: PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION, provider: CloudProvider::Onedrive, evidence_kind: "fileproviderctl-global-dump".into(), + observed_at_ms: 1, + admission_blocked_since_ms: None, evidence_complete: true, state: ProviderGlobalSyncState::Clear, upload_progress_present: false, diff --git a/src-tauri/tests/provider_global_sync_clear_state_integrity.rs b/src-tauri/tests/provider_global_sync_clear_state_integrity.rs index ce8a80bd9..277a49bb9 100644 --- a/src-tauri/tests/provider_global_sync_clear_state_integrity.rs +++ b/src-tauri/tests/provider_global_sync_clear_state_integrity.rs @@ -15,6 +15,8 @@ fn clear_report() -> ProviderGlobalSyncReport { schema_version: PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION, provider: CloudProvider::Onedrive, evidence_kind: "fileproviderctl-global-dump".into(), + observed_at_ms: 1, + admission_blocked_since_ms: None, evidence_complete: true, state: ProviderGlobalSyncState::Clear, upload_progress_present: false, diff --git a/src-tauri/tests/provider_global_sync_disk_full_code_boundary.rs b/src-tauri/tests/provider_global_sync_disk_full_code_boundary.rs index 61284d34b..c6a9c3a71 100644 --- a/src-tauri/tests/provider_global_sync_disk_full_code_boundary.rs +++ b/src-tauri/tests/provider_global_sync_disk_full_code_boundary.rs @@ -8,20 +8,31 @@ use disksage_lib::provider_global_sync::{parse_dump, ProviderGlobalSyncState}; #[test] fn code_28_marker_requires_a_numeric_boundary() { - let unrelated = "com.google.drivefs.fpext\nsync engine state:\n error:'NSFileProviderErrorDomain Code=280 unrelated provider failure'\n"; - let unrelated_report = parse_dump(CloudProvider::GoogleDrive, unrelated).unwrap(); - assert_eq!(unrelated_report.state, ProviderGlobalSyncState::Error); - assert!(unrelated_report - .blockers - .contains(&"provider-global-sync-error".into())); - assert!(!unrelated_report - .blockers - .contains(&"provider-global-sync-local-disk-full".into())); + for marker in [ + "NSFileProviderErrorDomain Code=280 unrelated provider failure", + "write failed: errno 280", + "write failed: odresult_errno 280", + "write failed: OSStatus -340", + ] { + let unrelated = + format!("com.google.drivefs.fpext\nsync engine state:\n error:'{marker}'\n"); + let unrelated_report = parse_dump(CloudProvider::GoogleDrive, &unrelated).unwrap(); + assert_eq!(unrelated_report.state, ProviderGlobalSyncState::Error); + assert!(unrelated_report + .blockers + .contains(&"provider-global-sync-error".into())); + assert!(!unrelated_report + .blockers + .contains(&"provider-global-sync-local-disk-full".into())); + } for marker in [ "NSFileProviderErrorDomain Code=28 write failed", "NSFileProviderErrorDomain Code 28 write failed", "NSFileProviderErrorDomain Code=28", + "write failed: errno 28", + "write failed: odresult_errno 28", + "write failed: OSStatus -34", ] { let dump = format!("com.google.drivefs.fpext\nsync engine state:\n {marker}\n"); let report = parse_dump(CloudProvider::GoogleDrive, &dump).unwrap(); diff --git a/src/lib/CloudArchive.svelte b/src/lib/CloudArchive.svelte index 0392ba454..c1bd7e507 100644 --- a/src/lib/CloudArchive.svelte +++ b/src/lib/CloudArchive.svelte @@ -18,8 +18,13 @@ boundedCloudArchiveErrorMessage, isCloudCopyCancelled, } from "./cloudArchiveErrorFeedback"; + import { + blockedSinceMs as resolveBlockedSinceMs, + icloudBlockedSinceMs as resolveIcloudBlockedSinceMs, + } from "./cloudArchiveHealthTiming"; import { fmtBytes } from "./fmt"; import IcloudLocalEviction from "./IcloudLocalEviction.svelte"; + import { buildCloudLineageExport } from "./cloudLineageExport"; const RECONCILIATION_INTERVAL_MS = 60_000; // fileproviderctl can spend tens of seconds inside the system provider database while iCloud is @@ -34,6 +39,7 @@ ]); const PROVIDER_FINDER_COPY_BLOCKERS = new Set([ "provider-global-sync-transfer-active", + "provider-global-sync-indexing-pending", "provider-global-sync-reconciliation-pending", "provider-global-sync-temporarily-disconnected", "provider-global-sync-server-unreachable", @@ -238,7 +244,7 @@ && (!candidate.requires_review || exactApproval) && (embeddedHighConfidence || exactApproval) && capacityEvidenceAvailable - && api.localCopyHasHeadroom(report?.local_volume, candidate.bytes) + && !nativeCopyHeadroomBlocked(candidate) && !providerAdmissionBlocked && !icloudAdmissionBlocked && !icloudPreCopyEvidenceBlocked @@ -246,7 +252,18 @@ } function nativeCopyHeadroomBlocked(candidate: api.CloudCandidate): boolean { - return !api.localCopyHasHeadroom(report?.local_volume, candidate.bytes); + const candidateBlocked = candidate.blocked_reason === "local-volume-headroom-insufficient" + || candidate.blocked_reason === "local-volume-headroom-unverified"; + if (candidateBlocked) return true; + // Older reports carried only a plan-wide notice. Keep those fail-closed while allowing + // current reports to admit candidates whose own destination probe passed. + const hasPerCandidateEvidence = report?.candidates.some((item) => + item.blocked_reason === "local-volume-headroom-insufficient" + || item.blocked_reason === "local-volume-headroom-unverified" + ) === true; + return !hasPerCandidateEvidence + && (report?.notices.includes("local-volume-headroom-insufficient") === true + || report?.notices.includes("local-volume-headroom-unverified") === true); } function providerApiWriteConnected(): boolean { @@ -263,10 +280,12 @@ const embeddedHighConfidence = candidate.production_time_confidence === "high" && candidate.production_time_source.startsWith("embedded:"); const approvalPhrase = api.cloudCopyApprovalPhrase(candidate, "copy-only"); + const onlyNativeStagingBlocker = candidate.blocked_reason === null + || candidate.blocked_reason.startsWith("local-volume-headroom-"); return selectedRootDetails()?.provider !== "icloud" && hasProviderAdmissionBlocker(report?.notices ?? []) && providerApiWriteConnected() - && candidate.blocked_reason === null + && onlyNativeStagingBlocker && (!candidate.requires_review || exactApproval) && (embeddedHighConfidence || exactApproval) && api.cloudCapacityAllowsCopy(report?.capacity) @@ -496,6 +515,19 @@ } } + function downloadLineageExport() { + if (!copied) return; + const graph = buildCloudLineageExport(copied, attestation, eviction); + if (!graph) return; + const blob = new Blob([JSON.stringify(graph, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `disksage-lineage-${graph.content_id.slice(0, 12)}.json`; + anchor.click(); + URL.revokeObjectURL(url); + } + async function reconcileCloudReceipts() { reconciling = true; reconciliationError = ""; @@ -532,6 +564,7 @@ activity?.no_progress_create_count ?? 0, activity?.materialization_failure_count ?? 0, activity?.staged_item_missing_count ?? 0, + activity?.pending_indexable_count ?? "", activity?.active_upload_count ?? 0, activity?.active_download_count ?? 0, activity?.active_upload_progress_millionths ?? "", @@ -544,7 +577,10 @@ icloudHealthBlockedSinceMs = 0; icloudHealthFingerprint = ""; } else if (icloudHealthFingerprint !== fingerprint) { - icloudHealthBlockedSinceMs = observedAtMs; + icloudHealthBlockedSinceMs = resolveIcloudBlockedSinceMs( + next.admission_blocked_since_ms, + next.observed_at_ms, + ); icloudHealthFingerprint = fingerprint; } icloudHealth = next; @@ -602,6 +638,13 @@ try { const observedAtMs = Date.now(); const next = await api.inspectCloudProviderGlobalSync(root.path); + const backendObservedAtMs = Number.isInteger(next.observed_at_ms) && next.observed_at_ms > 0 + ? next.observed_at_ms + : observedAtMs; + const backendBlockedSinceMs = resolveBlockedSinceMs( + next.admission_blocked_since_ms, + backendObservedAtMs, + ); const fingerprint = [ next.provider, next.state, @@ -614,12 +657,14 @@ providerGlobalSyncBlockedSinceMs = 0; providerGlobalSyncFingerprint = ""; } else if (providerGlobalSyncFingerprint !== fingerprint) { - providerGlobalSyncBlockedSinceMs = observedAtMs; + providerGlobalSyncBlockedSinceMs = backendBlockedSinceMs; providerGlobalSyncFingerprint = fingerprint; + } else if (backendBlockedSinceMs < providerGlobalSyncBlockedSinceMs) { + providerGlobalSyncBlockedSinceMs = backendBlockedSinceMs; } providerGlobalSync = next; - providerGlobalSyncObservedAtMs = observedAtMs; - providerGlobalSyncNextCheckAt = observedAtMs + providerGlobalSyncObservedAtMs = backendObservedAtMs; + providerGlobalSyncNextCheckAt = backendObservedAtMs + (next.blockers.length === 0 ? RECONCILIATION_INTERVAL_MS : PROVIDER_GLOBAL_SYNC_BLOCKED_RETRY_INTERVAL_MS); @@ -823,17 +868,20 @@ "icloud-native-sync-up-pending": "macOS iCloud sync-up이 아직 끝나지 않음", "icloud-native-sync-down-pending": "macOS iCloud sync-down이 아직 끝나지 않음", "icloud-native-status-evidence-incomplete": "macOS iCloud 상태 증거가 불완전함", - "icloud-native-status-command-timeout": "macOS iCloud 상태 확인이 시간 초과되어 복사를 보류함", - "icloud-file-provider-no-progress": "File Provider fetch/create 요청이 진행률 없이 정지함", - "icloud-file-provider-materialization-failed": "File Provider 파일 materialization이 실패함(staged item 없음)", - "icloud-file-provider-item-locked": "File Provider 항목이 전파 잠금 상태임", - "icloud-file-provider-stalled": "File Provider 오래된 오류로 전송이 정지된 상태임", + "icloud-native-status-command-timeout": "iCloud 상태 확인이 늦어지고 있습니다. 잠시 후 다시 확인하세요.", + "icloud-native-status-pending-scan": "iCloud가 파일 목록을 준비 중입니다. 완료될 때까지 복사를 기다리세요.", + "icloud-file-provider-no-progress": "iCloud 요청이 진행되지 않습니다. Finder 복사를 취소한 뒤 다시 확인하세요.", + "icloud-file-provider-materialization-failed": "iCloud가 파일을 준비하지 못했습니다. Finder 복사를 취소한 뒤 다시 시도하세요.", + "icloud-file-provider-item-locked": "iCloud가 파일을 처리 중입니다. Finder 작업을 취소하고 잠시 후 다시 확인하세요.", + "icloud-file-provider-stalled": "iCloud 전송이 오래 멈춰 있습니다. Finder 복사를 취소한 뒤 다시 확인하세요.", "icloud-file-provider-filename-excluded": "iCloud가 파일 이름 때문에 동기화에서 제외한 항목이 있음", "icloud-file-provider-root-excluded": "iCloud가 동기화 루트에서 제외한 항목이 있음", - "icloud-file-provider-transfer-active": "File Provider 기존 upload/download가 진행 중임", - "icloud-file-provider-dump-timeout": "File Provider 상태 확인이 시간 초과됨", - "icloud-file-provider-dump-output-truncated": "File Provider 상태 증거가 잘려 불완전함", - "icloud-file-provider-evidence-unavailable": "File Provider 상태 증거를 확인할 수 없음", + "icloud-file-provider-indexing-pending": "iCloud가 파일 목록을 준비 중입니다. 기존 전송이 끝난 뒤 다시 확인하세요.", + "icloud-file-provider-disk-import-active": "iCloud가 로컬 파일을 정리 중입니다. 완료될 때까지 새 복사를 기다리세요.", + "icloud-file-provider-transfer-active": "iCloud의 기존 업로드 또는 다운로드가 끝난 뒤 다시 확인하세요.", + "icloud-file-provider-dump-timeout": "iCloud 상태 확인이 늦어지고 있습니다. 잠시 후 다시 확인하세요.", + "icloud-file-provider-dump-output-truncated": "iCloud 상태를 모두 확인하지 못했습니다. 잠시 후 다시 확인하세요.", + "icloud-file-provider-evidence-unavailable": "iCloud 상태를 확인할 수 없습니다. 연결과 여유 공간을 확인하세요.", "icloud-item-error-octagon-not-signed-in": "iCloud 계정 인증이 필요함", "icloud-item-error-older-than-24h": "iCloud 동기화 오류가 24시간 이상 지속됨", }; @@ -843,8 +891,8 @@ function providerGlobalSyncBlockerLabel(blocker: string): string { const labels: Record = { "provider-global-sync-transfer-active": "전역 파일 전송이 진행 중임", - "provider-global-sync-indexing-pending": "공급자 인덱싱이 끝나지 않음", - "provider-global-sync-reconciliation-pending": "공급자 reconciliation 대기 항목이 있음", + "provider-global-sync-indexing-pending": "파일 목록 준비가 끝난 뒤 다시 확인하세요.", + "provider-global-sync-reconciliation-pending": "클라우드 확인 작업이 끝난 뒤 다시 확인하세요.", "provider-global-sync-filename-too-long": "파일명 제한 오류가 있음", "provider-global-sync-temporarily-disconnected": "공급자가 일시적으로 연결 해제됨", "provider-global-sync-server-unreachable": "공급자 서버에 연결할 수 없음", @@ -910,19 +958,19 @@ - 화면이 열려 있는 동안 클라우드 쓰기·원본 삭제 없이 provider 증거와 ADR/Goal을 갱신합니다. iCloud가 막히면 자동 확인은 최대 5분 간격으로 줄어듭니다. + 화면이 열려 있는 동안 파일을 변경하지 않고 동기화 상태를 갱신합니다. iCloud가 지연되면 5분 간격으로 다시 확인합니다. {#if selectedRootDetails() && !selectedRootDetails()?.readable}

- 이 File Provider 루트는 현재 읽을 수 없습니다. 공급자 전역 상태 진단과 고정된 데스크톱 클라이언트 복구만 허용하며, - 복사·attestation·원본 정리는 루트가 다시 읽힐 때까지 차단합니다. + 이 클라우드 폴더를 읽을 수 없습니다. 클라우드 앱을 다시 연 뒤 상태를 확인하세요. + 폴더를 다시 읽을 수 있을 때까지 복사와 원본 정리는 보류됩니다.

{/if} {#if reconciliation}
재시작 후 영수증 재검증 - {reconciliation.receipts_seen}개 확인 · {reconciliation.attested_count}개 provider 증거 갱신 · + {reconciliation.receipts_seen}개 확인 · {reconciliation.attested_count}개 업로드 상태 갱신 · {reconciliation.pending_count}개 업로드 대기 · {reconciliation.error_count}개 확인 실패 {#if reconciliation.incomplete_reconciliation} · {reconciliation.unprocessed_count}개 미처리{/if} @@ -939,32 +987,37 @@

{/each} {/if} -

이 작업은 provider 증거와 동적 ADR/Goal만 갱신하며 클라우드 쓰기·원본 삭제는 수행하지 않습니다.

+

이 작업은 이전 복사 상태만 다시 확인하며 파일을 변경하거나 삭제하지 않습니다.

{/if} {#if reconciliationError}{/if} {#if icloudHealth}
- iCloud 새 복사 admission + iCloud 복사 준비 상태 - {icloudHealth.new_copy_admission_state === "clear" ? "새 복사 허용 가능" : "새 복사 차단"} · + {icloudHealth.new_copy_admission_state === "clear" ? "지금 복사 가능" : "복사 보류"} · 대기 {icloudHealth.upload_queue.scheduled_waiting_count}개 · 진행 {icloudHealth.upload_queue.scheduled_active_count}개 · - sync-up 차단 {icloudHealth.upload_queue.blocked_on_sync_up_count}개 · + 업로드 보류 {icloudHealth.upload_queue.blocked_on_sync_up_count}개 · 오류 {icloudHealth.upload_queue.item_error_count}개 {#if icloudHealth.file_provider_activity} - · File Provider 무진행 fetch {icloudHealth.file_provider_activity.no_progress_fetch_count}개 / create {icloudHealth.file_provider_activity.no_progress_create_count}개 · - materialization 실패 {icloudHealth.file_provider_activity.materialization_failure_count}개 / staged item 없음 {icloudHealth.file_provider_activity.staged_item_missing_count}개 · - 활성 upload {icloudHealth.file_provider_activity.active_upload_count}개 / download {icloudHealth.file_provider_activity.active_download_count}개 + · 응답 없는 요청 {icloudHealth.file_provider_activity.no_progress_fetch_count + icloudHealth.file_provider_activity.no_progress_create_count}개 · + 파일 준비 실패 {icloudHealth.file_provider_activity.materialization_failure_count + icloudHealth.file_provider_activity.staged_item_missing_count}개 · + 파일 목록 준비 {icloudHealth.file_provider_activity.pending_indexable_count ?? 0}개 · + 로컬 파일 정리 {icloudHealth.file_provider_activity.notices.includes("icloud-file-provider-disk-import-active") ? "진행 중" : "없음"} · + 기존 업로드 {icloudHealth.file_provider_activity.active_upload_count}개 / 다운로드 {icloudHealth.file_provider_activity.active_download_count}개 {#if providerProgressPercent(icloudHealth.file_provider_activity.active_upload_progress_millionths)} - · upload 진행률 {providerProgressPercent(icloudHealth.file_provider_activity.active_upload_progress_millionths)} + · 업로드 진행률 {providerProgressPercent(icloudHealth.file_provider_activity.active_upload_progress_millionths)} {/if} {#if providerProgressPercent(icloudHealth.file_provider_activity.active_download_progress_millionths)} - · download 진행률 {providerProgressPercent(icloudHealth.file_provider_activity.active_download_progress_millionths)} + · 다운로드 진행률 {providerProgressPercent(icloudHealth.file_provider_activity.active_download_progress_millionths)} {/if} {/if} + {#if icloudHealth.native_status} + · 추가 확인 대기 {icloudHealth.native_status.pending_scan_count ?? 0}개 + {/if} -

마지막 증거 확인: {evidenceObservedAt(icloudHealth.observed_at_ms)}

+

마지막 확인: {evidenceObservedAt(icloudHealth.observed_at_ms)}

{#if icloudHealthBlockedSinceMs > 0}

동일 차단 지속: {duration(Math.max(0, icloudHealth.observed_at_ms - icloudHealthBlockedSinceMs))} @@ -972,8 +1025,8 @@ {/if} {#if hasIcloudHealthEvidencePersistenceFailure(icloudHealth.notices)}

- iCloud 동기화 요약 증거를 저장하지 못했습니다. 이번 관찰값은 표시하되 장기 비교에는 사용하지 않으며, - 복사·원본 정리 판정은 현재 증거가 다시 저장될 때까지 보수적으로 유지합니다. + iCloud 상태 기록을 저장하지 못했습니다. 여유 공간을 확보한 뒤 “상태 다시 확인”을 누르세요. + 상태가 저장될 때까지 복사와 원본 정리는 보류됩니다.

{/if} {#if icloudHealth.new_copy_admission_blockers.length > 0} @@ -986,12 +1039,17 @@ || icloudHealth.file_provider_activity.no_progress_create_count > 0 || icloudHealth.file_provider_activity.materialization_failure_count > 0 || icloudHealth.file_provider_activity.staged_item_missing_count > 0 + || (icloudHealth.file_provider_activity.pending_indexable_count ?? 0) > 0 + || icloudHealth.file_provider_activity.notices.includes("icloud-file-provider-disk-import-active") || icloudHealth.file_provider_activity.timed_out || icloudHealth.file_provider_activity.active_upload_count > 0 || icloudHealth.file_provider_activity.active_download_count > 0 || icloudHealth.new_copy_admission_blockers.includes("icloud-file-provider-item-locked") || icloudHealth.new_copy_admission_blockers.includes("icloud-file-provider-stalled") - )} + ) || (icloudHealth.native_status?.pending_scan_count ?? 0) > 0} +

+ Finder 복사를 취소하면 현재 화면의 대기 요청만 중지합니다. 파일이나 클라우드 데이터는 변경되지 않습니다. +

@@ -999,48 +1057,66 @@ {/if} {#if icloudHealth.file_provider_activity && (icloudHealth.file_provider_activity.no_progress_fetch_count > 0 || icloudHealth.file_provider_activity.no_progress_create_count > 0)}

- Finder가 “복사 준비 중”에서 멈춘 동안 File Provider의 no-progress 요청이 함께 관찰되었습니다. Finder에 남은 복사 대기는 취소하고, - File Provider 상태가 정상으로 관찰된 뒤 DiskSage에서 새 계획을 다시 실행해야 합니다. + Finder가 “복사 준비 중”에서 멈춰 있습니다. Finder에 남은 복사 대기를 취소하고, + iCloud 전송이 정상화된 뒤 “상태 다시 확인”을 누르세요. +

+ {/if} + {#if (icloudHealth.native_status?.pending_scan_count ?? 0) > 0} +

+ iCloud가 아직 {icloudHealth.native_status?.pending_scan_count}개 항목을 확인 중입니다. + Finder 전송이 끝난 뒤 “상태 다시 확인”을 누르세요. 완료 전에는 새 복사와 원본 정리를 진행하지 않습니다.

{/if} {#if icloudHealth.file_provider_activity && (icloudHealth.file_provider_activity.materialization_failure_count > 0 || icloudHealth.file_provider_activity.staged_item_missing_count > 0)}

- File Provider가 파일 materialization에 실패했거나 staged item을 잃었습니다. 현재 복사는 완료로 간주하지 않으며, - 새 복사·attestation·원본 정리는 상태가 정상화될 때까지 차단합니다. + iCloud가 파일을 준비하지 못했습니다. Finder 복사를 취소하고 “상태 다시 확인”을 누르세요. + 상태가 정상화될 때까지 새 복사와 원본 정리는 보류됩니다.

{/if} {#if icloudHealth.new_copy_admission_blockers.includes("icloud-file-provider-item-locked")}

- File Provider 항목의 전파 잠금 상태가 Finder 복사 준비 지연과 함께 관찰되었습니다. Finder의 대기 작업을 취소하고, - 상태가 정상화된 뒤 DiskSage에서 새 복사를 다시 시작하십시오. + iCloud가 파일을 처리 중이라 Finder 복사가 기다리고 있습니다. Finder의 대기 작업을 취소하고, + 잠시 후 “상태 다시 확인”을 누르세요.

{/if} {#if icloudHealth.new_copy_admission_blockers.includes("icloud-file-provider-stalled")}

- File Provider 큐에서 15분 이상 묵은 fetch/create 오류가 관찰되었습니다. Finder의 “복사 준비 중” 작업을 취소하고, - 상태가 정상화된 뒤 DiskSage에서 새 복사를 다시 시작하십시오. + iCloud 전송이 15분 이상 진행되지 않았습니다. Finder의 “복사 준비 중” 작업을 취소하고, + 잠시 후 “상태 다시 확인”을 누르세요.

{/if} {#if icloudHealth.file_provider_activity?.timed_out}

- File Provider 상태 확인이 제한시간을 넘었습니다. Finder에 남은 복사 대기를 취소하고, - DiskSage에서 상태를 다시 확인한 뒤 admission이 clear일 때만 새 복사를 시작하십시오. + iCloud 상태 확인이 제한시간을 넘었습니다. Finder에 남은 복사 대기를 취소하고, + “상태 다시 확인” 결과가 복사 가능일 때만 새 복사를 시작하세요.

{/if} {#if icloudHealth.file_provider_activity && (icloudHealth.file_provider_activity.active_upload_count > 0 || icloudHealth.file_provider_activity.active_download_count > 0)}

- iCloud에 기존 전송이 진행 중입니다. 기존 upload/download가 끝나고 새 복사 admission이 - clear가 될 때까지 Finder 복사와 원본 정리를 진행하지 않습니다. + iCloud에 기존 전송이 진행 중입니다. 업로드와 다운로드가 끝난 뒤 “상태 다시 확인”을 누르세요. + 화면에 “지금 복사 가능”이 표시될 때까지 새 복사와 원본 정리는 보류됩니다. +

+ {/if} + {#if (icloudHealth.file_provider_activity?.pending_indexable_count ?? 0) > 0} +

+ iCloud가 {icloudHealth.file_provider_activity?.pending_indexable_count}개 파일의 목록을 준비 중입니다. + 기존 전송이 끝난 뒤 “상태 다시 확인”을 누르세요. 그전에는 복사를 완료로 간주하지 않습니다. +

+ {/if} + {#if icloudHealth.file_provider_activity?.notices.includes("icloud-file-provider-disk-import-active")} +

+ iCloud가 로컬 파일을 정리 중입니다. 작업이 끝난 뒤 “상태 다시 확인”을 누르세요. + 완료 전에는 새 복사와 원본 정리를 시작하지 않습니다.

{/if} {#if icloudHealthBlockedSinceMs > 0 && icloudHealth.observed_at_ms - icloudHealthBlockedSinceMs >= PROVIDER_STALL_WARNING_MS}

동일한 iCloud 차단 상태가 15분 이상 지속되었습니다. Finder에 남은 복사 대기를 취소하고, - iCloud 상태가 clear가 될 때까지 새 복사·attestation·원본 정리를 시작하지 마십시오. + “상태 다시 확인” 결과가 “지금 복사 가능”이 될 때까지 새 복사와 원본 정리를 시작하지 마세요.

{/if} {:else} -

iCloud 전역 업로드 대기열이 비어 있습니다. 개별 파일은 별도 provider 증거가 필요합니다.

+

iCloud 업로드 대기열이 비어 있습니다. 복사할 파일의 업로드 상태를 확인한 뒤 원본을 정리하세요.

{/if} {#if typeof icloudHealth.managed_database_allocated_bytes === "number"}

@@ -1057,25 +1133,25 @@ .join(", ")}

{/if} -

읽기 전용 로컬 증거이며, 원격 용량·개별 파일 업로드 완료·원본 삭제 권한을 대신 증명하지 않습니다.

+

이 상태만으로 원격 여유 공간이나 개별 파일의 업로드 완료를 확인할 수 없습니다. 원본 정리 전에 파일별 상태를 확인하세요.

{/if} {#if icloudHealthError}

- iCloud File Provider 증거를 확인하지 못했습니다. Finder에 남은 복사 대기를 취소하고, - 로컬 여유공간을 확보한 뒤 DiskSage에서 상태를 다시 확인하십시오. + iCloud 상태를 확인하지 못했습니다. Finder에 남은 복사 대기를 취소하고, + 로컬 여유 공간을 확보한 뒤 “상태 다시 확인”을 누르세요.

{/if} {#if providerGlobalSync}
- {providerGlobalSync.provider} 전역 동기화 admission + {providerGlobalSync.provider} 복사 준비 상태 - {providerGlobalSync.state === "clear" && providerGlobalSync.blockers.length === 0 ? "새 복사 허용 가능" : "새 복사 차단"} · + {providerGlobalSync.state === "clear" && providerGlobalSync.blockers.length === 0 ? "지금 복사 가능" : "복사 보류"} · 업로드 전송 {providerGlobalSync.upload_progress_present ? "진행 중" : "없음"} · 다운로드 전송 {providerGlobalSync.download_progress_present ? "진행 중" : "없음"} {#if providerGlobalSync.pending_indexable_count !== null} - · 인덱싱 대기 {providerGlobalSync.pending_indexable_count}개 + · 파일 목록 준비 {providerGlobalSync.pending_indexable_count}개 {/if} · 마지막 관찰 {evidenceObservedAt(providerGlobalSyncObservedAtMs)} · {providerGlobalSync.blockers.length === 0 ? "1분" : "5분"} 후 자동 재확인 @@ -1089,15 +1165,18 @@

{#if providerGlobalSyncBlockedSinceMs > 0 && providerGlobalSyncObservedAtMs - providerGlobalSyncBlockedSinceMs >= PROVIDER_STALL_WARNING_MS}

- 동일한 공급자 차단 상태가 15분 이상 지속되었습니다. Finder에 남은 복사 대기를 취소하고, - 공급자 앱을 재기동한 뒤 상태가 clear가 될 때까지 새 복사·attestation·원본 정리를 시작하지 마십시오. + 동일한 클라우드 지연이 15분 이상 지속되었습니다. Finder에 남은 복사 대기를 취소하고, + 클라우드 앱을 다시 연 뒤 상태를 확인하세요. “지금 복사 가능”이 표시될 때까지 새 복사와 원본 정리는 보류됩니다.

{/if} {#if selectedRootDetails()?.provider !== "icloud"} {#if canCancelFinderCopyForProviderGlobalSync(providerGlobalSync)} +

+ 이 작업은 Finder에 Escape 키를 보내므로 macOS 손쉬운 사용 설정에서 DiskSage의 System Events 제어 권한이 필요합니다. 권한이 없으면 요청만 실패하며 파일·클라우드 데이터는 변경되지 않습니다. +

@@ -1105,7 +1184,7 @@ {/if} {/if} {:else} -

공급자 전역 동기화 대기열이 비어 있습니다. 개별 파일은 별도 provider 증거가 필요합니다.

+

클라우드 업로드 대기열이 비어 있습니다. 복사할 파일의 업로드 상태를 확인한 뒤 원본을 정리하세요.

{/if} {#if providerRecovery}

0} class="muted"> @@ -1114,14 +1193,14 @@ {#if providerRecovery.blockers.length > 0} · {providerRecovery.blockers.join(", ")}{/if}

{/if} -

읽기 전용 File Provider 집계 증거이며, 클라우드 쓰기·개별 파일 attestation·원본 삭제 권한을 대신 증명하지 않습니다.

+

이 상태 확인은 파일을 변경하지 않습니다. 원본 정리 전에 복사한 파일의 업로드 완료를 확인하세요.

{/if} {#if providerGlobalSyncError} - +

- 공급자 전역 증거를 확인하지 못했습니다. Finder에 남은 복사 대기를 취소하고, - 공급자 앱이 정상으로 관찰될 때까지 새 복사·attestation·원본 정리를 시작하지 마십시오. + Finder에 남은 복사 대기를 취소하고 클라우드 앱을 다시 여세요. + “지금 복사 가능”이 표시될 때까지 새 복사와 원본 정리는 보류됩니다.

{/if} {#if roots.some((root) => !root.readable)} @@ -1268,8 +1347,8 @@ {/if} {#if report.candidates.some(nativeCopyHeadroomBlocked)}

- 네이티브 File Provider 복사는 후보 크기와 {fmtBytes(api.LOCAL_COPY_RESERVE_BYTES)} 여유공간을 함께 확보해야 합니다. - 현재 여유공간이 부족한 후보는 버튼을 비활성화합니다. 명시적 OAuth 공급자 API 업로드는 별도 경로입니다. + 네이티브 File Provider 복사는 목적지 staging 볼륨에 후보 크기와 {fmtBytes(api.LOCAL_COPY_RESERVE_BYTES)} 여유공간을 함께 확보해야 합니다. + 목적지 여유공간이 부족하거나 확인되지 않은 경우 native 버튼을 비활성화합니다. 명시적 OAuth 공급자 API 업로드는 별도 경로입니다.

{/if} {/if} @@ -1338,6 +1417,12 @@
영수증 {copied.receipt.receipt_id} · {fmtBytes(copied.receipt.bytes)}
{copied.receipt.destination}

Goal: {copied.goal_state} · 상태: {copied.goal_status ?? "미확인"} · 동적 ADR: {copied.adr_path ?? "실패"} · 동적 Goal: {copied.goal_path ?? "실패"}

+ {#if buildCloudLineageExport(copied, attestation, eviction)} + +

원본·목적지 경로 없이 stable content ID, metadata provenance, provider sync, Goal, eviction 관계와 차단 사유만 내보냅니다.

+ {/if} {#each copied.projection_warnings as warning}

동적 ADR/Goal 투영 경고: {warning}

{/each} diff --git a/src/lib/api.ts b/src/lib/api.ts index 7f0e79b13..80f025e3c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -826,6 +826,8 @@ export interface LocalVolumeSnapshot { export interface IcloudSyncHealthReport { observed_at_ms: number; + /** Earliest retained observation for the current admission-blocker run. */ + admission_blocked_since_ms?: number | null; evidence_complete: boolean; managed_database_allocated_bytes?: number; upload_queue: { @@ -835,6 +837,12 @@ export interface IcloudSyncHealthReport { out_of_quota_count: number; item_error_count: number; }; + native_status?: { + status_observed: boolean; + evidence_complete: boolean; + pending_scan_count?: number; + notices: string[]; + } | null; file_provider_activity?: { command_succeeded: boolean; timed_out: boolean; @@ -845,6 +853,7 @@ export interface IcloudSyncHealthReport { staged_item_missing_count: number; sync_excluded_filename_count: number; sync_excluded_root_count: number; + pending_indexable_count?: number | null; active_upload_count: number; active_download_count: number; active_upload_progress_millionths?: number | null; @@ -865,6 +874,8 @@ export interface ProviderGlobalSyncReport { schema_version: number; provider: Exclude; evidence_kind: string; + observed_at_ms: number; + admission_blocked_since_ms?: number | null; evidence_complete: boolean; state: ProviderGlobalSyncState; upload_progress_present: boolean; diff --git a/src/lib/cloudArchiveAdmissionContract.test.ts b/src/lib/cloudArchiveAdmissionContract.test.ts index adfa4303c..7f1f04a5a 100644 --- a/src/lib/cloudArchiveAdmissionContract.test.ts +++ b/src/lib/cloudArchiveAdmissionContract.test.ts @@ -8,64 +8,116 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..") describe("CloudArchive iCloud admission contract", () => { it("clears stale health evidence when refresh fails", () => { const source = readFileSync(resolve(repositoryRoot, "src/lib/CloudArchive.svelte"), "utf8"); + const apiSource = readFileSync(resolve(repositoryRoot, "src/lib/api.ts"), "utf8"); expect(source).toContain("icloudHealth = null;"); expect(source).toContain("icloudHealth?.new_copy_admission_state !== \"clear\""); expect(source).toContain("managed_database_allocated_bytes"); expect(source).toContain("시스템 관리 데이터를 삭제하지 않습니다"); expect(source).toContain("icloud-item-error-octagon-not-signed-in"); expect(source).toContain("동기화 진단:"); - expect(source).toContain("iCloud File Provider 증거를 확인하지 못했습니다."); + expect(source).toContain("iCloud 상태를 확인하지 못했습니다."); expect(source).toContain("no_progress_create_count"); + expect(source).toContain("pending_indexable_count"); + expect(source).toContain("pending_scan_count"); + expect(source).toContain("icloud-native-status-pending-scan"); + expect(source).toContain("icloud-file-provider-indexing-pending"); + expect(source).toContain("icloud-file-provider-disk-import-active"); + expect(source).toContain("로컬 파일 정리"); expect(source).toContain("providerProgressPercent"); expect(source).toContain("active_upload_progress_millionths"); - expect(source).toContain("Finder가 “복사 준비 중”에서 멈춘 동안 File Provider의 no-progress 요청이 함께 관찰되었습니다."); + expect(source).toContain("Finder가 “복사 준비 중”에서 멈춰 있습니다."); expect(source).not.toContain("Finder가 “복사 준비 중”에서 멈춘 원인은"); - expect(source).toContain("Finder에 남은 복사 대기는 취소"); - expect(source).toContain("File Provider 상태 확인이 제한시간을 넘었습니다"); + expect(source).toContain("Finder에 남은 복사 대기를 취소"); + expect(source).toContain("iCloud 상태 확인이 늦어지고 있습니다. 잠시 후 다시 확인하세요."); expect(source).toContain("Lineage 연결관계"); expect(source).toContain("검증 복사 영수증 → provider attestation → Goal/ADR"); + expect(source).toContain('from "./cloudLineageExport"'); + expect(source).toContain("downloadLineageExport()"); + expect(source).toContain("path-free lineage JSON 내보내기"); + expect(source).toContain("원본·목적지 경로 없이 stable content ID"); expect(source).toContain("candidate.metadata_fingerprint"); - expect(source).toContain("마지막 증거 확인:"); + expect(source).toContain("마지막 확인:"); expect(source).toContain("evidenceObservedAt(icloudHealth.observed_at_ms)"); expect(source).toContain("ICLOUD_HEALTH_BLOCKED_RETRY_INTERVAL_MS"); expect(source).toContain("icloudHealthNextCheckAt"); expect(source).toContain("icloudHealthBlockedSinceMs"); expect(source).toContain("icloudHealthFingerprint"); expect(source).toContain("const admissionClear = next.new_copy_admission_state === \"clear\""); + expect(source).toContain('from "./cloudArchiveHealthTiming"'); + expect(source).toContain("resolveIcloudBlockedSinceMs("); + expect(source).toContain("next.admission_blocked_since_ms,"); + expect(source).toContain("next.observed_at_ms,"); + expect(source).not.toContain("next.admission_blocked_since_ms ?? observedAtMs"); + expect(apiSource).toContain("admission_blocked_since_ms?: number | null;"); expect(source).toContain("동일한 iCloud 차단 상태가 15분 이상 지속되었습니다."); expect(source).toContain("refreshIcloudHealth(true)"); expect(source).toContain("refreshProviderGlobalSync(true)"); expect(source).toContain("const observedAtMs = Date.now();"); expect(source).toContain("providerGlobalSyncBlockedSinceMs"); + expect(source).toContain("const backendObservedAtMs = Number.isInteger(next.observed_at_ms)"); + expect(source).toContain("const backendBlockedSinceMs = resolveBlockedSinceMs("); + expect(source).toContain("providerGlobalSyncObservedAtMs = backendObservedAtMs;"); + expect(source).toContain("providerGlobalSyncBlockedSinceMs = backendBlockedSinceMs;"); expect(source).toContain("PROVIDER_GLOBAL_SYNC_BLOCKED_RETRY_INTERVAL_MS"); expect(source).toContain("providerGlobalSyncNextCheckAt"); expect(source).toContain("checkingProviderGlobalSync || (!force && Date.now() < providerGlobalSyncNextCheckAt)"); expect(source).toContain("next.pending_indexable_count !== null && next.pending_indexable_count > 0"); expect(source).toContain("provider-global-sync-item-not-found"); expect(source).toContain("icloud-file-provider-item-locked"); - expect(source).toContain("File Provider 항목이 전파 잠금 상태임"); - expect(source).toContain("File Provider 항목의 전파 잠금 상태가 Finder 복사 준비 지연과 함께 관찰되었습니다."); - expect(source).toContain("File Provider 큐에서 15분 이상 묵은 fetch/create 오류가 관찰되었습니다."); + expect(source).toContain("iCloud가 파일을 처리 중입니다. Finder 작업을 취소하고 잠시 후 다시 확인하세요."); expect(source).toContain("icloud-file-provider-stalled"); expect(source).not.toContain("Finder의 복사 준비가 진행되지 않습니다."); expect(source).toContain("동일 차단 지속"); - expect(source).toContain("동일한 공급자 차단 상태가 15분 이상 지속되었습니다."); - expect(source).toContain("공급자 전역 증거를 확인하지 못했습니다."); + expect(source).toContain("동일한 클라우드 지연이 15분 이상 지속되었습니다."); + expect(source).toContain("클라우드 상태를 확인하지 못했습니다:"); expect(source).toContain("마지막 관찰 {evidenceObservedAt(providerGlobalSyncObservedAtMs)}"); expect(source).toContain('providerGlobalSync.blockers.length === 0 ? "1분" : "5분"'); expect(source).toContain("후 자동 재확인"); expect(source).toContain("접근 불가·진단만 가능"); - expect(source).toContain("공급자 전역 상태 진단과 고정된 데스크톱 클라이언트 복구만 허용"); + expect(source).toContain("이 클라우드 폴더를 읽을 수 없습니다. 클라우드 앱을 다시 연 뒤 상태를 확인하세요."); expect(source).toContain("!selectedRootDetails()?.readable"); expect(source).toContain("async function cancelFinderCopy()"); expect(source).toContain("await api.cancelFinderCopy();"); expect(source).toContain("cancellingFinderCopy || checkingIcloudHealth"); expect(source).toContain("canCancelFinderCopyForProviderGlobalSync"); expect(source).toContain("provider-global-sync-reconciliation-pending"); + expect(source).toContain("provider-global-sync-indexing-pending"); expect(source).toContain("provider-global-sync-local-disk-full"); expect(source).toContain("provider-global-sync-item-not-found"); expect(source).toContain("cancellingFinderCopy || checkingProviderGlobalSync"); expect(source).toContain("finderCopyCancelStatus = \"Finder 복사 취소 요청을 보냈습니다. 상태를 다시 확인하십시오.\""); + expect(source).toContain("macOS 손쉬운 사용 설정에서 DiskSage의 System Events 제어 권한이 필요합니다"); + expect(source).toContain("권한이 없으면 요청만 실패하며 파일·클라우드 데이터는 변경되지 않습니다"); + expect(source).toContain("local-volume-headroom-insufficient"); + expect(source).toContain("local-volume-headroom-unverified"); + expect(source).toContain("local-volume-headroom-"); + expect(source).toContain("const candidateBlocked = candidate.blocked_reason"); + expect(source).toContain("const hasPerCandidateEvidence = report?.candidates.some"); + expect(source).toContain("!nativeCopyHeadroomBlocked(candidate)"); + expect(source).not.toContain("api.localCopyHasHeadroom(report?.local_volume, candidate.bytes)"); + }); + + it("keeps the iCloud status panel customer-facing and actionable", () => { + const source = readFileSync(resolve(repositoryRoot, "src/lib/CloudArchive.svelte"), "utf8"); + const panelStart = source.indexOf("iCloud 복사 준비 상태"); + const panelEnd = source.indexOf("{#if providerGlobalSync}", panelStart); + + expect(panelStart).toBeGreaterThanOrEqual(0); + expect(panelEnd).toBeGreaterThan(panelStart); + const panel = source.slice(panelStart, panelEnd); + const customerCopy = panel.replaceAll(/\{[^}]*\}/gs, " ").replaceAll(/<[^>]*>/gs, " "); + expect(customerCopy).toContain("상태 다시 확인"); + expect(customerCopy).toContain("Finder 복사"); + for (const internalTerm of [ + "File Provider", + "materialization", + "staged item", + "pending-scan", + "attestation", + " admission", + ]) { + expect(customerCopy).not.toContain(internalTerm); + } }); it("exposes cancellation only for the cancellable native copy path", () => { diff --git a/src/lib/cloudArchiveHealthTiming.test.ts b/src/lib/cloudArchiveHealthTiming.test.ts new file mode 100644 index 000000000..a2dac25ad --- /dev/null +++ b/src/lib/cloudArchiveHealthTiming.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { blockedSinceMs, icloudBlockedSinceMs } from "./cloudArchiveHealthTiming"; + +describe("iCloud health blocker timing", () => { + it("uses the backend observation clock when persisted blocked-since is absent", () => { + expect(icloudBlockedSinceMs(null, 20_000)).toBe(20_000); + expect(icloudBlockedSinceMs(undefined, 30_000)).toBe(30_000); + }); + + it("preserves the backend-provided blocker onset", () => { + expect(icloudBlockedSinceMs(10_000, 20_000)).toBe(10_000); + }); + + it("rejects impossible persisted onset values", () => { + for (const onset of [-1, 20_001, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(icloudBlockedSinceMs(onset, 20_000)).toBe(20_000); + } + }); + + it("uses the same persisted timing contract for third-party providers", () => { + expect(blockedSinceMs(40_000, 50_000)).toBe(40_000); + expect(blockedSinceMs(null, 50_000)).toBe(50_000); + }); +}); diff --git a/src/lib/cloudArchiveHealthTiming.ts b/src/lib/cloudArchiveHealthTiming.ts new file mode 100644 index 000000000..a7e09984f --- /dev/null +++ b/src/lib/cloudArchiveHealthTiming.ts @@ -0,0 +1,19 @@ +export function blockedSinceMs( + admissionBlockedSinceMs: number | null | undefined, + backendObservedAtMs: number, +): number { + const persistedOnsetIsUsable = typeof admissionBlockedSinceMs === "number" + && Number.isSafeInteger(admissionBlockedSinceMs) + && admissionBlockedSinceMs >= 0 + && admissionBlockedSinceMs <= backendObservedAtMs; + return persistedOnsetIsUsable + ? admissionBlockedSinceMs + : backendObservedAtMs; +} + +export function icloudBlockedSinceMs( + admissionBlockedSinceMs: number | null | undefined, + backendObservedAtMs: number, +): number { + return blockedSinceMs(admissionBlockedSinceMs, backendObservedAtMs); +} diff --git a/src/lib/cloudLineageExport.test.ts b/src/lib/cloudLineageExport.test.ts new file mode 100644 index 000000000..3cf2d506d --- /dev/null +++ b/src/lib/cloudLineageExport.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { buildCloudLineageExport } from "./cloudLineageExport"; +import type { CloudAttestationOutput, CloudCopyOutput } from "./api"; + +const copied = { + action: "copy-only", + goal_state: "pending-provider-sync", + goal_status: "blocked", + receipt: { + candidate_fingerprint: "a".repeat(64), + receipt_id: "b".repeat(64), + provider: "icloud", + copy_verified: true, + lineage_fingerprint: "c".repeat(64), + lineage: { + production_time_source: "embedded:com.apple.metadata:kMDItemFSCreationDate", + production_time_confidence: "high", + }, + }, +} as unknown as CloudCopyOutput; + +const attestation = { + evidence: { sync_state: "pending-upload" }, + blockers: ["provider-sync-incomplete", "icloud-indexing-pending"], +} as unknown as CloudAttestationOutput; + +describe("cloud lineage export", () => { + it("exports path-free stable graph edges and blockers", () => { + const exported = buildCloudLineageExport(copied, attestation, null, 123); + + expect(exported).not.toBeNull(); + expect(exported).toMatchObject({ + schema: "disksage.cloud-lineage", + version: 1, + generated_at_ms: 123, + provider: "icloud", + provider_sync_state: "pending-upload", + remote_object_id: null, + remote_revision: null, + remote_location_bound: null, + blockers: ["icloud-indexing-pending", "provider-sync-incomplete"], + local_paths_included: false, + }); + expect(exported?.edges.map((edge) => edge.predicate)).toEqual([ + "has-metadata-evidence", + "archived-to", + "managed-by", + "has-copy-receipt", + "projects-goal", + "attested-by", + ]); + }); + + it("adds the eviction relation only after a real eviction output exists", () => { + const eviction = { + goal_state: "source-evicted", + approval: { approval_id: "d".repeat(64) }, + } as never; + + const exported = buildCloudLineageExport(copied, null, eviction, 456); + expect(exported?.nodes.at(-1)).toEqual({ + id: `eviction:${"d".repeat(64)}`, + kind: "eviction", + status: "source-evicted", + }); + expect(exported?.edges.at(-1)).toEqual({ + subject: `goal:${"b".repeat(64)}`, + predicate: "authorizes", + object: `eviction:${"d".repeat(64)}`, + }); + }); + + it("links an identified remote provider item without exposing a path", () => { + const remote = { + ...attestation, + evidence: { + sync_state: "complete", + remote_content: { object_id: "provider-item-1", revision: "rev-2", location_bound: true }, + }, + blockers: [], + } as unknown as CloudAttestationOutput; + + const exported = buildCloudLineageExport(copied, remote, null, 789); + expect(exported?.remote_object_id).toBe("provider-item-1"); + expect(exported?.remote_revision).toBe("rev-2"); + expect(exported?.remote_location_bound).toBe(true); + expect(exported?.nodes).toContainEqual({ + id: "provider-item:provider-item-1", + kind: "provider-item", + status: "identified", + }); + expect(exported?.edges.map((edge) => edge.predicate)).toContain("matches-provider-item"); + }); + + it("fails closed when a legacy receipt has no lineage fingerprint", () => { + expect( + buildCloudLineageExport( + { ...copied, receipt: { ...copied.receipt, lineage_fingerprint: undefined } } as CloudCopyOutput, + null, + null, + ), + ).toBeNull(); + }); +}); diff --git a/src/lib/cloudLineageExport.ts b/src/lib/cloudLineageExport.ts new file mode 100644 index 000000000..50725ec4f --- /dev/null +++ b/src/lib/cloudLineageExport.ts @@ -0,0 +1,131 @@ +import type { + CloudAttestationOutput, + CloudCopyOutput, + CloudSourceEvictionOutput, +} from "./api"; + +export interface CloudLineageExportNode { + id: string; + kind: "source" | "metadata" | "archive" | "provider" | "provider-item" | "receipt" | "goal" | "eviction"; + status: string; +} + +export interface CloudLineageExportEdge { + subject: string; + predicate: string; + object: string; +} + +export interface CloudLineageExport { + schema: "disksage.cloud-lineage"; + version: 1; + generated_at_ms: number; + content_id: string; + production_time_source: string; + production_time_confidence: string; + metadata_precedence: readonly [ + "embedded-metadata", + "explicit-filename-date", + "filesystem-created", + "filesystem-modified", + ]; + provider: CloudCopyOutput["receipt"]["provider"]; + provider_sync_state: CloudAttestationOutput["evidence"]["sync_state"]; + remote_object_id: string | null; + remote_revision: string | null; + remote_location_bound: boolean | null; + blockers: string[]; + local_paths_included: false; + nodes: CloudLineageExportNode[]; + edges: CloudLineageExportEdge[]; +} + +const metadataPrecedence = [ + "embedded-metadata", + "explicit-filename-date", + "filesystem-created", + "filesystem-modified", +] as const; + +const nodeId = (kind: CloudLineageExportNode["kind"], value: string): string => + `${kind}:${value}`; + +/** Build a stable, path-free lineage graph from the current receipt and evidence. */ +export function buildCloudLineageExport( + copied: CloudCopyOutput, + attestation: CloudAttestationOutput | null, + eviction: CloudSourceEvictionOutput | null, + generatedAtMs = Date.now(), +): CloudLineageExport | null { + const lineage = copied.receipt.lineage; + if (!lineage || !copied.receipt.lineage_fingerprint) return null; + + const source = nodeId("source", copied.receipt.candidate_fingerprint); + const metadata = nodeId("metadata", copied.receipt.candidate_fingerprint); + const archive = nodeId("archive", copied.receipt.lineage_fingerprint); + const provider = nodeId("provider", copied.receipt.provider); + const receipt = nodeId("receipt", copied.receipt.receipt_id); + const goal = nodeId("goal", copied.receipt.receipt_id); + const remoteObject = attestation?.evidence.remote_content?.object_id + ? nodeId("provider-item", attestation.evidence.remote_content.object_id) + : null; + const evictionNode = eviction ? nodeId("eviction", eviction.approval.approval_id) : null; + const syncState = attestation?.evidence.sync_state ?? "unknown"; + const blockers = [...(attestation?.blockers ?? [])].sort(); + + const nodes: CloudLineageExportNode[] = [ + { id: source, kind: "source", status: copied.receipt.copy_verified ? "verified" : "blocked" }, + { id: metadata, kind: "metadata", status: lineage.production_time_confidence }, + { id: archive, kind: "archive", status: copied.goal_state }, + { id: provider, kind: "provider", status: syncState }, + { id: receipt, kind: "receipt", status: copied.receipt.copy_verified ? "verified" : "blocked" }, + { id: goal, kind: "goal", status: copied.goal_status ?? "unknown" }, + ]; + if (remoteObject) { + nodes.splice(4, 0, { id: remoteObject, kind: "provider-item", status: "identified" }); + } + if (eviction) { + nodes.push({ + id: nodeId("eviction", eviction.approval.approval_id), + kind: "eviction", + status: eviction.goal_state, + }); + } + + const edges: CloudLineageExportEdge[] = [ + { subject: source, predicate: "has-metadata-evidence", object: metadata }, + { subject: source, predicate: "archived-to", object: archive }, + { subject: archive, predicate: "managed-by", object: provider }, + { subject: archive, predicate: "has-copy-receipt", object: receipt }, + { subject: receipt, predicate: "projects-goal", object: goal }, + ]; + if (attestation) { + edges.push({ subject: receipt, predicate: "attested-by", object: provider }); + } + if (remoteObject) { + edges.push({ subject: provider, predicate: "has-provider-item", object: remoteObject }); + edges.push({ subject: receipt, predicate: "matches-provider-item", object: remoteObject }); + } + if (evictionNode) { + edges.push({ subject: goal, predicate: "authorizes", object: evictionNode }); + } + + return { + schema: "disksage.cloud-lineage", + version: 1, + generated_at_ms: generatedAtMs, + content_id: copied.receipt.candidate_fingerprint, + production_time_source: lineage.production_time_source, + production_time_confidence: lineage.production_time_confidence, + metadata_precedence: metadataPrecedence, + provider: copied.receipt.provider, + provider_sync_state: syncState, + remote_object_id: attestation?.evidence.remote_content?.object_id ?? null, + remote_revision: attestation?.evidence.remote_content?.revision ?? null, + remote_location_bound: attestation?.evidence.remote_content?.location_bound ?? null, + blockers, + local_paths_included: false, + nodes, + edges, + }; +} diff --git a/src/lib/cloudOffloadGoalProjectionContract.test.ts b/src/lib/cloudOffloadGoalProjectionContract.test.ts index f80324450..7a27cf98c 100644 --- a/src/lib/cloudOffloadGoalProjectionContract.test.ts +++ b/src/lib/cloudOffloadGoalProjectionContract.test.ts @@ -17,6 +17,8 @@ describe("cloud-offload Goal projection contract", () => { runtime_evidence_failure_policy?: string; pre_copy_evidence_streams?: string[]; lineage_relation_identifier_rule?: string; + states?: string[]; + completion_gates?: string[]; }; expect(goal.operator_actions).toContain("cancel-finder-copy"); @@ -26,9 +28,12 @@ describe("cloud-offload Goal projection contract", () => { ])); expect(goal.runtime_evidence_failure_policy).toContain("fail-closed"); expect(goal.runtime_evidence_failure_policy).toContain("not process absence"); + expect(goal.states).toContain("provider-sync-incomplete"); + expect(goal.completion_gates).toContain("destination-headroom-bound"); expect(goal.pre_copy_evidence_streams).toEqual(expect.arrayContaining([ "provider-client-runtime-evidence", "icloud-sync-health-evidence", + "provider-global-sync-evidence", ])); expect(goal.lineage_relation_identifier_rule).toContain("never a raw local or provider path"); }); diff --git a/src/lib/cloudReviewQueue.test.ts b/src/lib/cloudReviewQueue.test.ts index dcf78716e..ce95948bc 100644 --- a/src/lib/cloudReviewQueue.test.ts +++ b/src/lib/cloudReviewQueue.test.ts @@ -285,6 +285,8 @@ describe("cloud review queue", () => { .toBe("공급자 동기화 증거가 불완전하여 기존 목적지를 채택할 수 없음"); expect(cloudDecisionReasonLabel("local-volume-headroom-insufficient")) .toBe("복사에 필요한 로컬 여유공간이 부족함"); + expect(cloudDecisionReasonLabel("local-volume-headroom-unverified")) + .toBe("복사 대상 볼륨의 여유공간을 확인하지 못함"); expect(cloudDecisionReasonLabel("future-review-reason")) .toBe("future-review-reason"); }); diff --git a/src/lib/cloudReviewQueue.ts b/src/lib/cloudReviewQueue.ts index ea5047db9..0149a2318 100644 --- a/src/lib/cloudReviewQueue.ts +++ b/src/lib/cloudReviewQueue.ts @@ -104,6 +104,7 @@ const CLOUD_DECISION_REASON_LABELS: Readonly> = { "exact-duplicate-content-probe-incomplete": "정확 중복 검사가 완전하지 않음", "filename-contains-geolocation": "파일명에 위치정보로 보이는 값이 있음", "local-volume-headroom-insufficient": "복사에 필요한 로컬 여유공간이 부족함", + "local-volume-headroom-unverified": "복사 대상 볼륨의 여유공간을 확인하지 못함", "filename-context-may-be-confidential": "파일명 맥락에 기밀정보 가능성이 있음", "incomplete-download": "다운로드가 완료되지 않은 파일임", "icloud-native-sync-up-pending": "iCloud 네이티브 상태가 업로드 대기 중이라 새 복사를 보류함",