diff --git a/.trajectories/completed/2026-07/traj_0ivieo04w496.json b/.trajectories/completed/2026-07/traj_0ivieo04w496.json new file mode 100644 index 00000000..383a8dc5 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_0ivieo04w496.json @@ -0,0 +1,57 @@ +{ + "id": "traj_0ivieo04w496", + "version": 1, + "task": { + "title": "Preserve scoped runtime state on integration disconnect", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T06:02:06.962Z", + "completedAt": "2026-07-30T06:02:43.614Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T06:02:43.550Z" + } + ], + "chapters": [ + { + "id": "chap_rqh3cwblwbgt", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T06:02:43.550Z", + "endedAt": "2026-07-30T06:02:43.614Z", + "events": [ + { + "ts": 1785391363551, + "type": "decision", + "content": "Preserve scoped .relay state while deleting disconnected provider content: Preserve scoped .relay state while deleting disconnected provider content", + "raw": { + "question": "Preserve scoped .relay state while deleting disconnected provider content", + "chosen": "Preserve scoped .relay state while deleting disconnected provider content", + "alternatives": [], + "reasoning": "A scoped child directory combines mirrored provider data with operational conflicts, dead letters, outbox, and cursor state. Whole-subtree deletion is unsafe; disconnect removes non-.relay entries at provider scopes and keeps sibling scopes untouched." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Changed integration disconnect to remove scoped provider mirror content without erasing child .relay operational state; focused disconnect and adopt-marker tests pass.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "7c78426109948000b19c1f32751e0bd2e8809f6a", + "endRef": "7c78426109948000b19c1f32751e0bd2e8809f6a" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_0ivieo04w496.md b/.trajectories/completed/2026-07/traj_0ivieo04w496.md new file mode 100644 index 00000000..020f8473 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_0ivieo04w496.md @@ -0,0 +1,32 @@ +# Trajectory: Preserve scoped runtime state on integration disconnect + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 90% +> **Started:** July 30, 2026 at 02:02 AM +> **Completed:** July 30, 2026 at 02:02 AM + +--- + +## Summary + +Changed integration disconnect to remove scoped provider mirror content without erasing child .relay operational state; focused disconnect and adopt-marker tests pass. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Preserve scoped .relay state while deleting disconnected provider content +- **Chose:** Preserve scoped .relay state while deleting disconnected provider content +- **Reasoning:** A scoped child directory combines mirrored provider data with operational conflicts, dead letters, outbox, and cursor state. Whole-subtree deletion is unsafe; disconnect removes non-.relay entries at provider scopes and keeps sibling scopes untouched. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Preserve scoped .relay state while deleting disconnected provider content: Preserve scoped .relay state while deleting disconnected provider content diff --git a/.trajectories/completed/2026-07/traj_0kko9s6utmhy.json b/.trajectories/completed/2026-07/traj_0kko9s6utmhy.json new file mode 100644 index 00000000..dda973f7 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_0kko9s6utmhy.json @@ -0,0 +1,69 @@ +{ + "id": "traj_0kko9s6utmhy", + "version": 1, + "task": { + "title": "Close exact-root and scoped-disconnect review findings", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T10:46:44.245Z", + "completedAt": "2026-07-30T10:48:54.739Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T10:46:44.599Z" + } + ], + "chapters": [ + { + "id": "chap_q1gn18sd0h46", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T10:46:44.599Z", + "endedAt": "2026-07-30T10:48:54.739Z", + "events": [ + { + "ts": 1785408404600, + "type": "decision", + "content": "Treat live exact runtime root as authoritative and catalog topology as fallback: Treat live exact runtime root as authoritative and catalog topology as fallback", + "raw": { + "question": "Treat live exact runtime root as authoritative and catalog topology as fallback", + "chosen": "Treat live exact runtime root as authoritative and catalog topology as fallback", + "alternatives": [], + "reasoning": "Exact mounts can retain stale catalog RemotePaths after remount; operator surfaces must follow the active root recorded in .relay/state.json." + }, + "significance": "high" + }, + { + "ts": 1785408404931, + "type": "decision", + "content": "Inspect scoped catalog-root compatibility state during provider disconnect: Inspect scoped catalog-root compatibility state during provider disconnect", + "raw": { + "question": "Inspect scoped catalog-root compatibility state during provider disconnect", + "chosen": "Inspect scoped catalog-root compatibility state during provider disconnect", + "alternatives": [], + "reasoning": "Cross-scope bulk dead letters are deliberately stored at the catalog root, so child-only inspection can falsely declare a destructive disconnect safe." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Made live exact runtime topology authoritative and added scoped catalog-root compatibility-state inspection before provider disconnect.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "777410815d0d0aeb9787909db462f5b012de3a2f", + "endRef": "777410815d0d0aeb9787909db462f5b012de3a2f" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_0kko9s6utmhy.md b/.trajectories/completed/2026-07/traj_0kko9s6utmhy.md new file mode 100644 index 00000000..33420f98 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_0kko9s6utmhy.md @@ -0,0 +1,37 @@ +# Trajectory: Close exact-root and scoped-disconnect review findings + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 90% +> **Started:** July 30, 2026 at 06:46 AM +> **Completed:** July 30, 2026 at 06:48 AM + +--- + +## Summary + +Made live exact runtime topology authoritative and added scoped catalog-root compatibility-state inspection before provider disconnect. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Treat live exact runtime root as authoritative and catalog topology as fallback +- **Chose:** Treat live exact runtime root as authoritative and catalog topology as fallback +- **Reasoning:** Exact mounts can retain stale catalog RemotePaths after remount; operator surfaces must follow the active root recorded in .relay/state.json. + +### Inspect scoped catalog-root compatibility state during provider disconnect +- **Chose:** Inspect scoped catalog-root compatibility state during provider disconnect +- **Reasoning:** Cross-scope bulk dead letters are deliberately stored at the catalog root, so child-only inspection can falsely declare a destructive disconnect safe. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Treat live exact runtime root as authoritative and catalog topology as fallback: Treat live exact runtime root as authoritative and catalog topology as fallback +- Inspect scoped catalog-root compatibility state during provider disconnect: Inspect scoped catalog-root compatibility state during provider disconnect diff --git a/.trajectories/completed/2026-07/traj_0rjz89laoif3.json b/.trajectories/completed/2026-07/traj_0rjz89laoif3.json new file mode 100644 index 00000000..487beacb --- /dev/null +++ b/.trajectories/completed/2026-07/traj_0rjz89laoif3.json @@ -0,0 +1,57 @@ +{ + "id": "traj_0rjz89laoif3", + "version": 1, + "task": { + "title": "Read scoped writeback status from private state", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T05:43:10.555Z", + "completedAt": "2026-07-30T05:43:18.024Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T05:43:17.959Z" + } + ], + "chapters": [ + { + "id": "chap_g7ymsuanoe6s", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T05:43:17.959Z", + "endedAt": "2026-07-30T05:43:18.024Z", + "events": [ + { + "ts": 1785390197960, + "type": "decision", + "content": "Treat private mount state as the pending-writeback source of truth: Treat private mount state as the pending-writeback source of truth", + "raw": { + "question": "Treat private mount state as the pending-writeback source of truth", + "chosen": "Treat private mount state as the pending-writeback source of truth", + "alternatives": [], + "reasoning": "The public child snapshot historically derives pending counts from an obsolete in-mirror legacy file. Operator status now resolves the exact private state identity for each scope and counts dirty tracked files there." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Made aggregate writeback status count dirty files from each scope's persisted private mount state; regression proves bogus public pending counts are ignored.", + "approach": "Standard approach", + "confidence": 0.93 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "a0fd23f1f24e02d6d4eb36d9c5d2aefe49412488", + "endRef": "a0fd23f1f24e02d6d4eb36d9c5d2aefe49412488" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_0rjz89laoif3.md b/.trajectories/completed/2026-07/traj_0rjz89laoif3.md new file mode 100644 index 00000000..c2c11f35 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_0rjz89laoif3.md @@ -0,0 +1,32 @@ +# Trajectory: Read scoped writeback status from private state + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 93% +> **Started:** July 30, 2026 at 01:43 AM +> **Completed:** July 30, 2026 at 01:43 AM + +--- + +## Summary + +Made aggregate writeback status count dirty files from each scope's persisted private mount state; regression proves bogus public pending counts are ignored. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Treat private mount state as the pending-writeback source of truth +- **Chose:** Treat private mount state as the pending-writeback source of truth +- **Reasoning:** The public child snapshot historically derives pending counts from an obsolete in-mirror legacy file. Operator status now resolves the exact private state identity for each scope and counts dirty tracked files there. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Treat private mount state as the pending-writeback source of truth: Treat private mount state as the pending-writeback source of truth diff --git a/.trajectories/completed/2026-07/traj_1sp08l8m1pe4.json b/.trajectories/completed/2026-07/traj_1sp08l8m1pe4.json new file mode 100644 index 00000000..a46f4c66 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_1sp08l8m1pe4.json @@ -0,0 +1,69 @@ +{ + "id": "traj_1sp08l8m1pe4", + "version": 1, + "task": { + "title": "Make retry and health independent of compatibility storage root", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T10:52:38.466Z", + "completedAt": "2026-07-30T10:54:45.729Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T10:52:38.824Z" + } + ], + "chapters": [ + { + "id": "chap_79kfrlfq6les", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T10:52:38.824Z", + "endedAt": "2026-07-30T10:54:45.729Z", + "events": [ + { + "ts": 1785408758825, + "type": "decision", + "content": "Route every scoped retry path from the persisted allowlist: Route every scoped retry path from the persisted allowlist", + "raw": { + "question": "Route every scoped retry path from the persisted allowlist", + "chosen": "Route every scoped retry path from the persisted allowlist", + "alternatives": [], + "reasoning": "Dead-letter storage placement is a compatibility detail and cannot safely determine the Syncer for a comma-joined multi-root operation." + }, + "significance": "high" + }, + { + "ts": 1785408759157, + "type": "decision", + "content": "Aggregate outbox health across workspaceStateDirs: Aggregate outbox health across workspaceStateDirs", + "raw": { + "question": "Aggregate outbox health across workspaceStateDirs", + "chosen": "Aggregate outbox health across workspaceStateDirs", + "alternatives": [], + "reasoning": "Scoped child roots are active runtime owners, but compatibility outbox records can remain at the catalog root and must stay observable." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Made scoped retry grouping independent of dead-letter storage root and included compatibility-root outbox state in workspace health.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "6b10be542d27cfd78ff2b7815a72d8811b8a99c3", + "endRef": "6b10be542d27cfd78ff2b7815a72d8811b8a99c3" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_1sp08l8m1pe4.md b/.trajectories/completed/2026-07/traj_1sp08l8m1pe4.md new file mode 100644 index 00000000..1cd7e9eb --- /dev/null +++ b/.trajectories/completed/2026-07/traj_1sp08l8m1pe4.md @@ -0,0 +1,37 @@ +# Trajectory: Make retry and health independent of compatibility storage root + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 90% +> **Started:** July 30, 2026 at 06:52 AM +> **Completed:** July 30, 2026 at 06:54 AM + +--- + +## Summary + +Made scoped retry grouping independent of dead-letter storage root and included compatibility-root outbox state in workspace health. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Route every scoped retry path from the persisted allowlist +- **Chose:** Route every scoped retry path from the persisted allowlist +- **Reasoning:** Dead-letter storage placement is a compatibility detail and cannot safely determine the Syncer for a comma-joined multi-root operation. + +### Aggregate outbox health across workspaceStateDirs +- **Chose:** Aggregate outbox health across workspaceStateDirs +- **Reasoning:** Scoped child roots are active runtime owners, but compatibility outbox records can remain at the catalog root and must stay observable. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Route every scoped retry path from the persisted allowlist: Route every scoped retry path from the persisted allowlist +- Aggregate outbox health across workspaceStateDirs: Aggregate outbox health across workspaceStateDirs diff --git a/.trajectories/completed/2026-07/traj_2a93tcgvb68h.json b/.trajectories/completed/2026-07/traj_2a93tcgvb68h.json new file mode 100644 index 00000000..bc4cfbc7 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_2a93tcgvb68h.json @@ -0,0 +1,25 @@ +{ + "id": "traj_2a93tcgvb68h", + "version": 1, + "task": { + "title": "Review changes against fix/scoped-multipath-cli-runtime" + }, + "status": "completed", + "startedAt": "2026-07-30T10:13:57.008Z", + "completedAt": "2026-07-30T10:16:19.338Z", + "agents": [], + "chapters": [], + "retrospective": { + "summary": "Reviewed scoped multi-path CLI changes; identified disconnect preflight issues around dual workspace IDs and provider-unfiltered pending state.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "82b3f9afb11e7b7745441d33702384084375c489", + "endRef": "82b3f9afb11e7b7745441d33702384084375c489" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_2a93tcgvb68h.md b/.trajectories/completed/2026-07/traj_2a93tcgvb68h.md new file mode 100644 index 00000000..68035f1a --- /dev/null +++ b/.trajectories/completed/2026-07/traj_2a93tcgvb68h.md @@ -0,0 +1,14 @@ +# Trajectory: Review changes against fix/scoped-multipath-cli-runtime + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** July 30, 2026 at 06:13 AM +> **Completed:** July 30, 2026 at 06:16 AM + +--- + +## Summary + +Reviewed scoped multi-path CLI changes; identified disconnect preflight issues around dual workspace IDs and provider-unfiltered pending state. + +**Approach:** Standard approach diff --git a/.trajectories/completed/2026-07/traj_31gi21o8cioq.json b/.trajectories/completed/2026-07/traj_31gi21o8cioq.json new file mode 100644 index 00000000..0644139c --- /dev/null +++ b/.trajectories/completed/2026-07/traj_31gi21o8cioq.json @@ -0,0 +1,65 @@ +{ + "id": "traj_31gi21o8cioq", + "version": 1, + "task": { + "title": "Review changes against fix/scoped-multipath-cli-runtime" + }, + "status": "completed", + "startedAt": "2026-07-30T10:55:05.524Z", + "completedAt": "2026-07-30T10:57:00.921Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T10:57:00.215Z" + } + ], + "chapters": [ + { + "id": "chap_jfm91c6grocn", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T10:57:00.215Z", + "endedAt": "2026-07-30T10:57:00.921Z", + "events": [ + { + "ts": 1785409020215, + "type": "decision", + "content": "Flagged scoped disconnect deletion of excluded infrastructure: Flagged scoped disconnect deletion of excluded infrastructure", + "raw": { + "question": "Flagged scoped disconnect deletion of excluded infrastructure", + "chosen": "Flagged scoped disconnect deletion of excluded infrastructure", + "alternatives": [], + "reasoning": "removeProviderMirror deletes every scope-root entry except .relay, including .git metadata that mountsync explicitly excludes from mirrored content" + }, + "significance": "high" + }, + { + "ts": 1785409020564, + "type": "decision", + "content": "Flagged bulk dead-letter refresh routing: Flagged bulk dead-letter refresh routing", + "raw": { + "question": "Flagged bulk dead-letter refresh routing", + "chosen": "Flagged bulk dead-letter refresh routing", + "alternatives": [], + "reasoning": "comma-separated cross-scope paths are routed by the combined string into the first child root, so another provider's disconnect preflight cannot see the record" + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Reviewed scoped multi-path operator changes; identified two data-safety issues", + "approach": "Standard approach", + "confidence": 0.93 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "c2fdcc6bf68d6f0101e8d0df91985210fce911b4", + "endRef": "c2fdcc6bf68d6f0101e8d0df91985210fce911b4" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_31gi21o8cioq.md b/.trajectories/completed/2026-07/traj_31gi21o8cioq.md new file mode 100644 index 00000000..c97acb04 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_31gi21o8cioq.md @@ -0,0 +1,36 @@ +# Trajectory: Review changes against fix/scoped-multipath-cli-runtime + +> **Status:** ✅ Completed +> **Confidence:** 93% +> **Started:** July 30, 2026 at 06:55 AM +> **Completed:** July 30, 2026 at 06:57 AM + +--- + +## Summary + +Reviewed scoped multi-path operator changes; identified two data-safety issues + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Flagged scoped disconnect deletion of excluded infrastructure +- **Chose:** Flagged scoped disconnect deletion of excluded infrastructure +- **Reasoning:** removeProviderMirror deletes every scope-root entry except .relay, including .git metadata that mountsync explicitly excludes from mirrored content + +### Flagged bulk dead-letter refresh routing +- **Chose:** Flagged bulk dead-letter refresh routing +- **Reasoning:** comma-separated cross-scope paths are routed by the combined string into the first child root, so another provider's disconnect preflight cannot see the record + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Flagged scoped disconnect deletion of excluded infrastructure: Flagged scoped disconnect deletion of excluded infrastructure +- Flagged bulk dead-letter refresh routing: Flagged bulk dead-letter refresh routing diff --git a/.trajectories/completed/2026-07/traj_3dm6nse5csdk.json b/.trajectories/completed/2026-07/traj_3dm6nse5csdk.json new file mode 100644 index 00000000..684c5f89 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_3dm6nse5csdk.json @@ -0,0 +1,57 @@ +{ + "id": "traj_3dm6nse5csdk", + "version": 1, + "task": { + "title": "Close Unit C second-review disconnect findings", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T10:17:52.747Z", + "completedAt": "2026-07-30T10:27:07.565Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T10:17:52.827Z" + } + ], + "chapters": [ + { + "id": "chap_armgon0suo7c", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T10:17:52.827Z", + "endedAt": "2026-07-30T10:27:07.565Z", + "events": [ + { + "ts": 1785406672828, + "type": "decision", + "content": "Filter disconnect safety evidence by provider and key private state by Relay runtime ID: Filter disconnect safety evidence by provider and key private state by Relay runtime ID", + "raw": { + "question": "Filter disconnect safety evidence by provider and key private state by Relay runtime ID", + "chosen": "Filter disconnect safety evidence by provider and key private state by Relay runtime ID", + "alternatives": [], + "reasoning": "A provider disconnect must refuse only for state it would remove, while dual-ID workspaces must inspect the exact private cursor identity the Syncer wrote. Counting an entire broad scope creates false refusals; preferring the Cloud catalog ID creates false clearance." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Closed Unit C's second-review disconnect findings by enumerating all private-state identities, refusing unknown state, and filtering broader-scope pending evidence by provider while treating malformed evidence conservatively.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "82b3f9afb11e7b7745441d33702384084375c489", + "endRef": "82b3f9afb11e7b7745441d33702384084375c489" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_3dm6nse5csdk.md b/.trajectories/completed/2026-07/traj_3dm6nse5csdk.md new file mode 100644 index 00000000..4f141bf7 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_3dm6nse5csdk.md @@ -0,0 +1,32 @@ +# Trajectory: Close Unit C second-review disconnect findings + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 90% +> **Started:** July 30, 2026 at 06:17 AM +> **Completed:** July 30, 2026 at 06:27 AM + +--- + +## Summary + +Closed Unit C's second-review disconnect findings by enumerating all private-state identities, refusing unknown state, and filtering broader-scope pending evidence by provider while treating malformed evidence conservatively. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Filter disconnect safety evidence by provider and key private state by Relay runtime ID +- **Chose:** Filter disconnect safety evidence by provider and key private state by Relay runtime ID +- **Reasoning:** A provider disconnect must refuse only for state it would remove, while dual-ID workspaces must inspect the exact private cursor identity the Syncer wrote. Counting an entire broad scope creates false refusals; preferring the Cloud catalog ID creates false clearance. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Filter disconnect safety evidence by provider and key private state by Relay runtime ID: Filter disconnect safety evidence by provider and key private state by Relay runtime ID diff --git a/.trajectories/completed/2026-07/traj_3gdyy15i9c13.json b/.trajectories/completed/2026-07/traj_3gdyy15i9c13.json new file mode 100644 index 00000000..ce891cb8 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_3gdyy15i9c13.json @@ -0,0 +1,89 @@ +{ + "id": "traj_3gdyy15i9c13", + "version": 1, + "task": { + "title": "Aggregate scoped mount operator surfaces", + "source": { + "system": "plain", + "id": "relayfile#379-unit-c" + } + }, + "status": "completed", + "startedAt": "2026-07-30T05:08:42.612Z", + "completedAt": "2026-07-30T05:16:54.894Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T05:14:05.545Z" + } + ], + "chapters": [ + { + "id": "chap_xvzgpz2rul14", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T05:14:05.545Z", + "endedAt": "2026-07-30T05:16:54.894Z", + "events": [ + { + "ts": 1785388445545, + "type": "decision", + "content": "Represent aggregate mount topology as exact, scoped, or unknown variants: Represent aggregate mount topology as exact, scoped, or unknown variants", + "raw": { + "question": "Represent aggregate mount topology as exact, scoped, or unknown variants", + "chosen": "Represent aggregate mount topology as exact, scoped, or unknown variants", + "alternatives": [], + "reasoning": "A per-child sync state truthfully has one remoteRoot, but an aggregate scoped status has remoteRoots and a legacy blank layout proves no root at all. Separate Go response types make a fabricated singular aggregate root unrepresentable while preserving the child state schema." + }, + "significance": "high" + }, + { + "ts": 1785388445607, + "type": "decision", + "content": "Aggregate writeback list from persisted runtime roots: Aggregate writeback list from persisted runtime roots", + "raw": { + "question": "Aggregate writeback list from persisted runtime roots", + "chosen": "Aggregate writeback list from persisted runtime roots", + "alternatives": [], + "reasoning": "The mechanical consumer inventory found writeback list after four review rounds missed it. Pending rows come from active child roots; dead rows also sweep the catalog root for legacy compatibility, with deterministic deduplication." + }, + "significance": "high" + }, + { + "ts": 1785388614832, + "type": "reflection", + "content": "The mechanical inventory collapsed the remaining multi-root consumer work to aggregate status topology, writeback/dead-letter consumers, skip-stuck, and writeback list. All four are implemented; full go vet and go test ./... pass. The stack constraint remains necessary because B alone still exposes lying root-level operator surfaces.", + "raw": { + "focalPoints": [ + "consumer-inventory", + "typed-topology", + "stack-constraint" + ], + "confidence": 0.93 + }, + "significance": "high", + "tags": [ + "focal:consumer-inventory", + "focal:typed-topology", + "focal:stack-constraint", + "confidence:0.93" + ] + } + ] + } + ], + "retrospective": { + "summary": "Made scoped mount operator surfaces truthful: typed exact/scoped/unknown status topology, aggregate health/status/writeback/dead-letter/replay, shared-limit skip-stuck, and aggregate writeback list. Full Go validation passes.", + "approach": "Standard approach", + "confidence": 0.93 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "42ec7270ed2ac5f7675bcbd474c0ad2052c3a4fe", + "endRef": "42ec7270ed2ac5f7675bcbd474c0ad2052c3a4fe" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_3gdyy15i9c13.md b/.trajectories/completed/2026-07/traj_3gdyy15i9c13.md new file mode 100644 index 00000000..5c26a343 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_3gdyy15i9c13.md @@ -0,0 +1,38 @@ +# Trajectory: Aggregate scoped mount operator surfaces + +> **Status:** ✅ Completed +> **Task:** relayfile#379-unit-c +> **Confidence:** 93% +> **Started:** July 30, 2026 at 01:08 AM +> **Completed:** July 30, 2026 at 01:16 AM + +--- + +## Summary + +Made scoped mount operator surfaces truthful: typed exact/scoped/unknown status topology, aggregate health/status/writeback/dead-letter/replay, shared-limit skip-stuck, and aggregate writeback list. Full Go validation passes. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Represent aggregate mount topology as exact, scoped, or unknown variants +- **Chose:** Represent aggregate mount topology as exact, scoped, or unknown variants +- **Reasoning:** A per-child sync state truthfully has one remoteRoot, but an aggregate scoped status has remoteRoots and a legacy blank layout proves no root at all. Separate Go response types make a fabricated singular aggregate root unrepresentable while preserving the child state schema. + +### Aggregate writeback list from persisted runtime roots +- **Chose:** Aggregate writeback list from persisted runtime roots +- **Reasoning:** The mechanical consumer inventory found writeback list after four review rounds missed it. Pending rows come from active child roots; dead rows also sweep the catalog root for legacy compatibility, with deterministic deduplication. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Represent aggregate mount topology as exact, scoped, or unknown variants: Represent aggregate mount topology as exact, scoped, or unknown variants +- Aggregate writeback list from persisted runtime roots: Aggregate writeback list from persisted runtime roots +- The mechanical inventory collapsed the remaining multi-root consumer work to aggregate status topology, writeback/dead-letter consumers, skip-stuck, and writeback list. All four are implemented; full go vet and go test ./... pass. The stack constraint remains necessary because B alone still exposes lying root-level operator surfaces. diff --git a/.trajectories/completed/2026-07/traj_86dy5h7bjo8i.json b/.trajectories/completed/2026-07/traj_86dy5h7bjo8i.json new file mode 100644 index 00000000..ff74002e --- /dev/null +++ b/.trajectories/completed/2026-07/traj_86dy5h7bjo8i.json @@ -0,0 +1,25 @@ +{ + "id": "traj_86dy5h7bjo8i", + "version": 1, + "task": { + "title": "Review changes against fix/scoped-multipath-cli-runtime" + }, + "status": "completed", + "startedAt": "2026-07-30T10:49:13.217Z", + "completedAt": "2026-07-30T10:51:23.530Z", + "agents": [], + "chapters": [], + "retrospective": { + "summary": "Reviewed scoped multipath operator-surface changes; identified cross-scope bulk dead-letter routing and compatibility-root health counter gaps", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "6b10be542d27cfd78ff2b7815a72d8811b8a99c3", + "endRef": "6b10be542d27cfd78ff2b7815a72d8811b8a99c3" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_86dy5h7bjo8i.md b/.trajectories/completed/2026-07/traj_86dy5h7bjo8i.md new file mode 100644 index 00000000..591e5f49 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_86dy5h7bjo8i.md @@ -0,0 +1,14 @@ +# Trajectory: Review changes against fix/scoped-multipath-cli-runtime + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** July 30, 2026 at 06:49 AM +> **Completed:** July 30, 2026 at 06:51 AM + +--- + +## Summary + +Reviewed scoped multipath operator-surface changes; identified cross-scope bulk dead-letter routing and compatibility-root health counter gaps + +**Approach:** Standard approach diff --git a/.trajectories/completed/2026-07/traj_a4qloqpqajl3.json b/.trajectories/completed/2026-07/traj_a4qloqpqajl3.json new file mode 100644 index 00000000..f372926b --- /dev/null +++ b/.trajectories/completed/2026-07/traj_a4qloqpqajl3.json @@ -0,0 +1,20 @@ +{ + "id": "traj_a4qloqpqajl3", + "version": 1, + "task": { + "title": "Review changes against fix/scoped-multipath-cli-runtime" + }, + "status": "abandoned", + "startedAt": "2026-07-30T11:00:27.036Z", + "completedAt": "2026-07-30T11:19:46.819Z", + "agents": [], + "chapters": [], + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "59e5f4ab21a094092801449bd33ce573b2f360b8", + "endRef": "59e5f4ab21a094092801449bd33ce573b2f360b8" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_a4qloqpqajl3.md b/.trajectories/completed/2026-07/traj_a4qloqpqajl3.md new file mode 100644 index 00000000..5c990cea --- /dev/null +++ b/.trajectories/completed/2026-07/traj_a4qloqpqajl3.md @@ -0,0 +1,5 @@ +# Trajectory: Review changes against fix/scoped-multipath-cli-runtime + +> **Status:** ❌ Abandoned +> **Started:** July 30, 2026 at 07:00 AM +> **Completed:** July 30, 2026 at 07:19 AM diff --git a/.trajectories/completed/2026-07/traj_bua53wamitju.json b/.trajectories/completed/2026-07/traj_bua53wamitju.json new file mode 100644 index 00000000..148e879e --- /dev/null +++ b/.trajectories/completed/2026-07/traj_bua53wamitju.json @@ -0,0 +1,25 @@ +{ + "id": "traj_bua53wamitju", + "version": 1, + "task": { + "title": "Review changes against fix/scoped-multipath-cli-runtime" + }, + "status": "completed", + "startedAt": "2026-07-30T10:34:12.908Z", + "completedAt": "2026-07-30T10:37:12.687Z", + "agents": [], + "chapters": [], + "retrospective": { + "summary": "Reviewed scoped operator-surface changes; identified cross-scope bulk dead-letter retry routing failure", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "bbde8a17097f26acbcaa3227f6e2be6372c750c0", + "endRef": "bbde8a17097f26acbcaa3227f6e2be6372c750c0" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_bua53wamitju.md b/.trajectories/completed/2026-07/traj_bua53wamitju.md new file mode 100644 index 00000000..d27dcfe9 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_bua53wamitju.md @@ -0,0 +1,14 @@ +# Trajectory: Review changes against fix/scoped-multipath-cli-runtime + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** July 30, 2026 at 06:34 AM +> **Completed:** July 30, 2026 at 06:37 AM + +--- + +## Summary + +Reviewed scoped operator-surface changes; identified cross-scope bulk dead-letter retry routing failure + +**Approach:** Standard approach diff --git a/.trajectories/completed/2026-07/traj_dowdovihdusp.json b/.trajectories/completed/2026-07/traj_dowdovihdusp.json new file mode 100644 index 00000000..de43f87e --- /dev/null +++ b/.trajectories/completed/2026-07/traj_dowdovihdusp.json @@ -0,0 +1,69 @@ +{ + "id": "traj_dowdovihdusp", + "version": 1, + "task": { + "title": "Keep delete-pending writebacks visible", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T11:23:34.612Z", + "completedAt": "2026-07-30T11:29:40.251Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T11:23:34.960Z" + } + ], + "chapters": [ + { + "id": "chap_vzkx9ec2zsgn", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T11:23:34.960Z", + "endedAt": "2026-07-30T11:29:40.251Z", + "events": [ + { + "ts": 1785410614960, + "type": "decision", + "content": "Treat deletePending as authoritative pending state: Treat deletePending as authoritative pending state", + "raw": { + "question": "Treat deletePending as authoritative pending state", + "chosen": "Treat deletePending as authoritative pending state", + "alternatives": [], + "reasoning": "A queued delete can have neither a local file nor a stored hash; dropping its explicit state hides a destructive operation and makes list disagree with status." + }, + "significance": "high" + }, + { + "ts": 1785410823200, + "type": "decision", + "content": "Make mountsync own the persisted tracked-file shape and pending predicate: Make mountsync own the persisted tracked-file shape and pending predicate", + "raw": { + "question": "Make mountsync own the persisted tracked-file shape and pending predicate", + "chosen": "Make mountsync own the persisted tracked-file shape and pending predicate", + "alternatives": [], + "reasoning": "Four consumers (writer public status, CLI aggregate status, disconnect preflight, and writeback list) must not re-declare either the JSON fields or Dirty/DeletePending semantics independently." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Centralized tracked-file JSON parsing and pending-writeback semantics, including delete-pending list coverage", + "approach": "Standard approach", + "confidence": 0.92 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "59e5f4ab21a094092801449bd33ce573b2f360b8", + "endRef": "59e5f4ab21a094092801449bd33ce573b2f360b8" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_dowdovihdusp.md b/.trajectories/completed/2026-07/traj_dowdovihdusp.md new file mode 100644 index 00000000..4caf0f9c --- /dev/null +++ b/.trajectories/completed/2026-07/traj_dowdovihdusp.md @@ -0,0 +1,37 @@ +# Trajectory: Keep delete-pending writebacks visible + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 92% +> **Started:** July 30, 2026 at 07:23 AM +> **Completed:** July 30, 2026 at 07:29 AM + +--- + +## Summary + +Centralized tracked-file JSON parsing and pending-writeback semantics, including delete-pending list coverage + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Treat deletePending as authoritative pending state +- **Chose:** Treat deletePending as authoritative pending state +- **Reasoning:** A queued delete can have neither a local file nor a stored hash; dropping its explicit state hides a destructive operation and makes list disagree with status. + +### Make mountsync own the persisted tracked-file shape and pending predicate +- **Chose:** Make mountsync own the persisted tracked-file shape and pending predicate +- **Reasoning:** Four consumers (writer public status, CLI aggregate status, disconnect preflight, and writeback list) must not re-declare either the JSON fields or Dirty/DeletePending semantics independently. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Treat deletePending as authoritative pending state: Treat deletePending as authoritative pending state +- Make mountsync own the persisted tracked-file shape and pending predicate: Make mountsync own the persisted tracked-file shape and pending predicate diff --git a/.trajectories/completed/2026-07/traj_fs8p6efpfqtt.json b/.trajectories/completed/2026-07/traj_fs8p6efpfqtt.json new file mode 100644 index 00000000..24420568 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_fs8p6efpfqtt.json @@ -0,0 +1,57 @@ +{ + "id": "traj_fs8p6efpfqtt", + "version": 1, + "task": { + "title": "Resolve scoped operator state through persisted private identities", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T05:32:48.408Z", + "completedAt": "2026-07-30T05:34:52.700Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T05:32:48.494Z" + } + ], + "chapters": [ + { + "id": "chap_pwtz2phwj7ai", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T05:32:48.494Z", + "endedAt": "2026-07-30T05:34:52.700Z", + "events": [ + { + "ts": 1785389568495, + "type": "decision", + "content": "Resolve every scoped operator read from the persisted mount identity: Resolve every scoped operator read from the persisted mount identity", + "raw": { + "question": "Resolve every scoped operator read from the persisted mount identity", + "chosen": "Resolve every scoped operator read from the persisted mount identity", + "alternatives": [], + "reasoning": "Normal mounts store private state under hashed MountStateDir paths. Operator commands must derive the same workspace, remote root, local root, state-file/state-dir, and mount-kind identity instead of assuming legacy files under each child." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Resolved scoped status, health, dead-letter, skip-stuck, and writeback reads through each persisted private mount-state identity; regression fixtures now use production hashed paths; full vet and Go suite pass.", + "approach": "Standard approach", + "confidence": 0.91 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "8fc6b8219ce48266ea7cf630be6eb0ded1206e4a", + "endRef": "8fc6b8219ce48266ea7cf630be6eb0ded1206e4a" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_fs8p6efpfqtt.md b/.trajectories/completed/2026-07/traj_fs8p6efpfqtt.md new file mode 100644 index 00000000..c4dbabdf --- /dev/null +++ b/.trajectories/completed/2026-07/traj_fs8p6efpfqtt.md @@ -0,0 +1,32 @@ +# Trajectory: Resolve scoped operator state through persisted private identities + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 91% +> **Started:** July 30, 2026 at 01:32 AM +> **Completed:** July 30, 2026 at 01:34 AM + +--- + +## Summary + +Resolved scoped status, health, dead-letter, skip-stuck, and writeback reads through each persisted private mount-state identity; regression fixtures now use production hashed paths; full vet and Go suite pass. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Resolve every scoped operator read from the persisted mount identity +- **Chose:** Resolve every scoped operator read from the persisted mount identity +- **Reasoning:** Normal mounts store private state under hashed MountStateDir paths. Operator commands must derive the same workspace, remote root, local root, state-file/state-dir, and mount-kind identity instead of assuming legacy files under each child. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Resolve every scoped operator read from the persisted mount identity: Resolve every scoped operator read from the persisted mount identity diff --git a/.trajectories/completed/2026-07/traj_gch0k1mre9do.json b/.trajectories/completed/2026-07/traj_gch0k1mre9do.json new file mode 100644 index 00000000..6250278f --- /dev/null +++ b/.trajectories/completed/2026-07/traj_gch0k1mre9do.json @@ -0,0 +1,69 @@ +{ + "id": "traj_gch0k1mre9do", + "version": 1, + "task": { + "title": "Address Unit C exact review findings", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T06:17:45.263Z", + "completedAt": "2026-07-30T06:20:10.418Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T06:20:10.290Z" + } + ], + "chapters": [ + { + "id": "chap_xdwswrf06nn4", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T06:20:10.290Z", + "endedAt": "2026-07-30T06:20:10.418Z", + "events": [ + { + "ts": 1785392410291, + "type": "decision", + "content": "Treat dirty and deletePending as one pending-mutation state: Treat dirty and deletePending as one pending-mutation state", + "raw": { + "question": "Treat dirty and deletePending as one pending-mutation state", + "chosen": "Treat dirty and deletePending as one pending-mutation state", + "alternatives": [], + "reasoning": "The Syncer persists both as authoritative work not yet accepted remotely. Every aggregate count and destructive preflight must share that definition or failed deletes disappear from status and safety checks." + }, + "significance": "high" + }, + { + "ts": 1785392410354, + "type": "decision", + "content": "Pass persisted scope roots into writeback-list reconstruction: Pass persisted scope roots into writeback-list reconstruction", + "raw": { + "question": "Pass persisted scope roots into writeback-list reconstruction", + "chosen": "Pass persisted scope roots into writeback-list reconstruction", + "alternatives": [], + "reasoning": "Private state can be durable before the public child snapshot. The catalog scope is already known and remains authoritative when .relay/state.json is absent, so re-deriving it from an optional artifact creates false root paths." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Addressed all three Unit C review findings: disconnect checks private dirty/delete-pending state, aggregate counts include pending deletes, and writeback list uses persisted scope roots when public state is absent. Focused regressions pass.", + "approach": "Standard approach", + "confidence": 0.94 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "219c36fa3ac1595fe880681554ca17f919a61dd4", + "endRef": "219c36fa3ac1595fe880681554ca17f919a61dd4" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_gch0k1mre9do.md b/.trajectories/completed/2026-07/traj_gch0k1mre9do.md new file mode 100644 index 00000000..f453a9e0 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_gch0k1mre9do.md @@ -0,0 +1,37 @@ +# Trajectory: Address Unit C exact review findings + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 94% +> **Started:** July 30, 2026 at 02:17 AM +> **Completed:** July 30, 2026 at 02:20 AM + +--- + +## Summary + +Addressed all three Unit C review findings: disconnect checks private dirty/delete-pending state, aggregate counts include pending deletes, and writeback list uses persisted scope roots when public state is absent. Focused regressions pass. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Treat dirty and deletePending as one pending-mutation state +- **Chose:** Treat dirty and deletePending as one pending-mutation state +- **Reasoning:** The Syncer persists both as authoritative work not yet accepted remotely. Every aggregate count and destructive preflight must share that definition or failed deletes disappear from status and safety checks. + +### Pass persisted scope roots into writeback-list reconstruction +- **Chose:** Pass persisted scope roots into writeback-list reconstruction +- **Reasoning:** Private state can be durable before the public child snapshot. The catalog scope is already known and remains authoritative when .relay/state.json is absent, so re-deriving it from an optional artifact creates false root paths. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Treat dirty and deletePending as one pending-mutation state: Treat dirty and deletePending as one pending-mutation state +- Pass persisted scope roots into writeback-list reconstruction: Pass persisted scope roots into writeback-list reconstruction diff --git a/.trajectories/completed/2026-07/traj_heylf6s1ddr1.json b/.trajectories/completed/2026-07/traj_heylf6s1ddr1.json new file mode 100644 index 00000000..10941176 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_heylf6s1ddr1.json @@ -0,0 +1,65 @@ +{ + "id": "traj_heylf6s1ddr1", + "version": 1, + "task": { + "title": "Review changes against fix/scoped-multipath-cli-runtime" + }, + "status": "completed", + "startedAt": "2026-07-30T11:36:43.414Z", + "completedAt": "2026-07-30T11:39:12.593Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T11:39:11.911Z" + } + ], + "chapters": [ + { + "id": "chap_zr4ia4p24pqz", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T11:39:11.911Z", + "endedAt": "2026-07-30T11:39:12.593Z", + "events": [ + { + "ts": 1785411551912, + "type": "decision", + "content": "Flag scoped disconnect data-loss gap: Flag scoped disconnect data-loss gap", + "raw": { + "question": "Flag scoped disconnect data-loss gap", + "chosen": "Flag scoped disconnect data-loss gap", + "alternatives": [], + "reasoning": "The new cleanup deletes scoped provider content, but its preflight only checks persisted dirty/delete flags and does not detect local hash drift or untracked files." + }, + "significance": "high" + }, + { + "ts": 1785411552248, + "type": "reflection", + "content": "Reviewed topology routing, writeback aggregation, retry routing, status aggregation, and disconnect safety; identified one high-impact safety bug and one timestamp ordering bug.", + "raw": { + "confidence": 0.9 + }, + "significance": "high", + "tags": [ + "confidence:0.9" + ] + } + ] + } + ], + "retrospective": { + "summary": "Reviewed changes against merge base and prepared prioritized findings.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "9ff464a113e97dd520cef5e61110b86b9f09768b", + "endRef": "9ff464a113e97dd520cef5e61110b86b9f09768b" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_heylf6s1ddr1.md b/.trajectories/completed/2026-07/traj_heylf6s1ddr1.md new file mode 100644 index 00000000..29167473 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_heylf6s1ddr1.md @@ -0,0 +1,32 @@ +# Trajectory: Review changes against fix/scoped-multipath-cli-runtime + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** July 30, 2026 at 07:36 AM +> **Completed:** July 30, 2026 at 07:39 AM + +--- + +## Summary + +Reviewed changes against merge base and prepared prioritized findings. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Flag scoped disconnect data-loss gap +- **Chose:** Flag scoped disconnect data-loss gap +- **Reasoning:** The new cleanup deletes scoped provider content, but its preflight only checks persisted dirty/delete flags and does not detect local hash drift or untracked files. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Flag scoped disconnect data-loss gap: Flag scoped disconnect data-loss gap +- Reviewed topology routing, writeback aggregation, retry routing, status aggregation, and disconnect safety; identified one high-impact safety bug and one timestamp ordering bug. diff --git a/.trajectories/completed/2026-07/traj_juhkgunm7iz9.json b/.trajectories/completed/2026-07/traj_juhkgunm7iz9.json new file mode 100644 index 00000000..f7fc980c --- /dev/null +++ b/.trajectories/completed/2026-07/traj_juhkgunm7iz9.json @@ -0,0 +1,126 @@ +{ + "id": "traj_juhkgunm7iz9", + "version": 1, + "task": { + "title": "Refuse disconnect on unobserved local drift", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T11:40:53.561Z", + "completedAt": "2026-07-30T12:33:29.593Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T11:40:53.918Z" + } + ], + "chapters": [ + { + "id": "chap_rtectra8tums", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T11:40:53.918Z", + "endedAt": "2026-07-30T12:33:29.593Z", + "events": [ + { + "ts": 1785411653919, + "type": "decision", + "content": "Use the writeback-list filesystem scan as disconnect evidence: Use the writeback-list filesystem scan as disconnect evidence", + "raw": { + "question": "Use the writeback-list filesystem scan as disconnect evidence", + "chosen": "Use the writeback-list filesystem scan as disconnect evidence", + "alternatives": [], + "reasoning": "Persisted dirty flags alone do not cover edits or new files made while the watcher is stopped; destructive cleanup must prove there is no pending local obligation." + }, + "significance": "high" + }, + { + "ts": 1785411654251, + "type": "decision", + "content": "Parse RFC3339Nano before selecting aggregate timestamps: Parse RFC3339Nano before selecting aggregate timestamps", + "raw": { + "question": "Parse RFC3339Nano before selecting aggregate timestamps", + "chosen": "Parse RFC3339Nano before selecting aggregate timestamps", + "alternatives": [], + "reasoning": "Variable-width fractional seconds are not lexicographically ordered." + }, + "significance": "high" + }, + { + "ts": 1785411729812, + "type": "decision", + "content": "Treat an absent scoped local directory as an empty local scan: Treat an absent scoped local directory as an empty local scan", + "raw": { + "question": "Treat an absent scoped local directory as an empty local scan", + "chosen": "Treat an absent scoped local directory as an empty local scan", + "alternatives": [], + "reasoning": "A private state file can exist before its child mirror is materialized; absence proves there are no local files, while permission and non-directory errors still refuse." + }, + "significance": "high" + }, + { + "ts": 1785411819069, + "type": "decision", + "content": "Make disconnect cleanup an observed allow-list: Make disconnect cleanup an observed allow-list", + "raw": { + "question": "Make disconnect cleanup an observed allow-list", + "chosen": "Make disconnect cleanup an observed allow-list", + "alternatives": [], + "reasoning": "Preflight cannot safely re-read after Cloud DELETE, so execution must delete only paths enumerated before the mutation and must verify observed file/symlink fingerprints; non-recursive directory removal preserves later arrivals." + }, + "significance": "high" + }, + { + "ts": 1785411819498, + "type": "decision", + "content": "Make malformed aggregate timestamps unknown: Make malformed aggregate timestamps unknown", + "raw": { + "question": "Make malformed aggregate timestamps unknown", + "chosen": "Make malformed aggregate timestamps unknown", + "alternatives": [], + "reasoning": "Selecting a valid sibling timestamp when any child timestamp is unparseable would report a confident latest value from partial state." + }, + "significance": "high" + }, + { + "ts": 1785412145267, + "type": "reflection", + "content": "The destructive-decision sweep expanded the same absent-evidence class beyond cursor flags", + "raw": { + "focalPoints": [ + "disconnect strictness", + "dead-letter refresh", + "transition cleanup" + ], + "adjustments": "Use strict filesystem counts in disconnect, validate the entire server feed before mirror mutation, and hold the transition cleanup race for Unit B placement", + "confidence": 0.9 + }, + "significance": "high", + "tags": [ + "focal:disconnect strictness", + "focal:dead-letter refresh", + "focal:transition cleanup", + "confidence:0.9" + ] + } + ] + } + ], + "retrospective": { + "summary": "Closed stopped-mount drift deletion, enumerated post-preflight cleanup, timestamp ordering, incomplete filesystem counts, and incomplete dead-letter feed pruning for Unit C.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "9ff464a113e97dd520cef5e61110b86b9f09768b", + "endRef": "9ff464a113e97dd520cef5e61110b86b9f09768b" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_juhkgunm7iz9.md b/.trajectories/completed/2026-07/traj_juhkgunm7iz9.md new file mode 100644 index 00000000..af2296db --- /dev/null +++ b/.trajectories/completed/2026-07/traj_juhkgunm7iz9.md @@ -0,0 +1,53 @@ +# Trajectory: Refuse disconnect on unobserved local drift + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 90% +> **Started:** July 30, 2026 at 07:40 AM +> **Completed:** July 30, 2026 at 08:33 AM + +--- + +## Summary + +Closed stopped-mount drift deletion, enumerated post-preflight cleanup, timestamp ordering, incomplete filesystem counts, and incomplete dead-letter feed pruning for Unit C. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Use the writeback-list filesystem scan as disconnect evidence +- **Chose:** Use the writeback-list filesystem scan as disconnect evidence +- **Reasoning:** Persisted dirty flags alone do not cover edits or new files made while the watcher is stopped; destructive cleanup must prove there is no pending local obligation. + +### Parse RFC3339Nano before selecting aggregate timestamps +- **Chose:** Parse RFC3339Nano before selecting aggregate timestamps +- **Reasoning:** Variable-width fractional seconds are not lexicographically ordered. + +### Treat an absent scoped local directory as an empty local scan +- **Chose:** Treat an absent scoped local directory as an empty local scan +- **Reasoning:** A private state file can exist before its child mirror is materialized; absence proves there are no local files, while permission and non-directory errors still refuse. + +### Make disconnect cleanup an observed allow-list +- **Chose:** Make disconnect cleanup an observed allow-list +- **Reasoning:** Preflight cannot safely re-read after Cloud DELETE, so execution must delete only paths enumerated before the mutation and must verify observed file/symlink fingerprints; non-recursive directory removal preserves later arrivals. + +### Make malformed aggregate timestamps unknown +- **Chose:** Make malformed aggregate timestamps unknown +- **Reasoning:** Selecting a valid sibling timestamp when any child timestamp is unparseable would report a confident latest value from partial state. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Use the writeback-list filesystem scan as disconnect evidence: Use the writeback-list filesystem scan as disconnect evidence +- Parse RFC3339Nano before selecting aggregate timestamps: Parse RFC3339Nano before selecting aggregate timestamps +- Treat an absent scoped local directory as an empty local scan: Treat an absent scoped local directory as an empty local scan +- Make disconnect cleanup an observed allow-list: Make disconnect cleanup an observed allow-list +- Make malformed aggregate timestamps unknown: Make malformed aggregate timestamps unknown +- The destructive-decision sweep expanded the same absent-evidence class beyond cursor flags diff --git a/.trajectories/completed/2026-07/traj_k2nocx979usx.json b/.trajectories/completed/2026-07/traj_k2nocx979usx.json new file mode 100644 index 00000000..00c338b3 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_k2nocx979usx.json @@ -0,0 +1,65 @@ +{ + "id": "traj_k2nocx979usx", + "version": 1, + "task": { + "title": "Review code changes against fix/scoped-multipath-cli-runtime" + }, + "status": "completed", + "startedAt": "2026-07-30T10:42:20.534Z", + "completedAt": "2026-07-30T10:45:37.827Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T10:45:37.163Z" + } + ], + "chapters": [ + { + "id": "chap_i1fum3ftusj3", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T10:45:37.163Z", + "endedAt": "2026-07-30T10:45:37.827Z", + "events": [ + { + "ts": 1785408337164, + "type": "decision", + "content": "Flagged scoped disconnect compatibility-state gap: Flagged scoped disconnect compatibility-state gap", + "raw": { + "question": "Flagged scoped disconnect compatibility-state gap", + "chosen": "Flagged scoped disconnect compatibility-state gap", + "alternatives": [], + "reasoning": "Provider-specific plans inspect matched child roots but omit catalog-root dead letters that the new compatibility routing explicitly supports" + }, + "significance": "high" + }, + { + "ts": 1785408337493, + "type": "reflection", + "content": "Review found two operator-surface regressions: exact-root routing can prefer stale catalog data over live state, and scoped disconnect can miss catalog-root pending records", + "raw": { + "confidence": 0.9 + }, + "significance": "high", + "tags": [ + "confidence:0.9" + ] + } + ] + } + ], + "retrospective": { + "summary": "Reviewed scoped multi-path operator changes and identified actionable routing/safety findings", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "777410815d0d0aeb9787909db462f5b012de3a2f", + "endRef": "777410815d0d0aeb9787909db462f5b012de3a2f" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_k2nocx979usx.md b/.trajectories/completed/2026-07/traj_k2nocx979usx.md new file mode 100644 index 00000000..a5dd9221 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_k2nocx979usx.md @@ -0,0 +1,32 @@ +# Trajectory: Review code changes against fix/scoped-multipath-cli-runtime + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** July 30, 2026 at 06:42 AM +> **Completed:** July 30, 2026 at 06:45 AM + +--- + +## Summary + +Reviewed scoped multi-path operator changes and identified actionable routing/safety findings + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Flagged scoped disconnect compatibility-state gap +- **Chose:** Flagged scoped disconnect compatibility-state gap +- **Reasoning:** Provider-specific plans inspect matched child roots but omit catalog-root dead letters that the new compatibility routing explicitly supports + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Flagged scoped disconnect compatibility-state gap: Flagged scoped disconnect compatibility-state gap +- Review found two operator-surface regressions: exact-root routing can prefer stale catalog data over live state, and scoped disconnect can miss catalog-root pending records diff --git a/.trajectories/completed/2026-07/traj_krf3a8bxjpku.json b/.trajectories/completed/2026-07/traj_krf3a8bxjpku.json new file mode 100644 index 00000000..7b3d7d6e --- /dev/null +++ b/.trajectories/completed/2026-07/traj_krf3a8bxjpku.json @@ -0,0 +1,157 @@ +{ + "id": "traj_krf3a8bxjpku", + "version": 1, + "task": { + "title": "Finish scoped multi-path operator surfaces", + "source": { + "system": "plain", + "id": "relayfile#379 / Unit C" + } + }, + "status": "completed", + "startedAt": "2026-07-30T06:43:30.836Z", + "completedAt": "2026-07-30T10:13:35.117Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T10:00:13.728Z" + } + ], + "chapters": [ + { + "id": "chap_nj5ow1ldkhuu", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T10:00:13.728Z", + "endedAt": "2026-07-30T10:13:35.117Z", + "events": [ + { + "ts": 1785405613729, + "type": "decision", + "content": "Represent missing exact remote-root state as unknown instead of silently defaulting to workspace root: Represent missing exact remote-root state as unknown instead of silently defaulting to workspace root", + "raw": { + "question": "Represent missing exact remote-root state as unknown instead of silently defaulting to workspace root", + "chosen": "Represent missing exact remote-root state as unknown instead of silently defaulting to workspace root", + "alternatives": [], + "reasoning": "A singular child root is truthful only when persisted topology or the child snapshot supplies it. Falling back to / converts absent state into an unscoped claim and can misroute pending writeback operations; operator surfaces now refuse when the legacy exact root cannot be established." + }, + "significance": "high" + }, + { + "ts": 1785406032690, + "type": "decision", + "content": "Closed all three Unit C review findings at their trust boundaries: Closed all three Unit C review findings at their trust boundaries", + "raw": { + "question": "Closed all three Unit C review findings at their trust boundaries", + "chosen": "Closed all three Unit C review findings at their trust boundaries", + "alternatives": [], + "reasoning": "Cloud-only disconnect now treats missing LocalDir as no local cleanup; legacy exact records recover their child root from persisted runtime state; scoped retry resolves catalog compatibility records to the matching child and child records take precedence over stale catalog duplicates." + }, + "significance": "high" + }, + { + "ts": 1785406415056, + "type": "reflection", + "content": "Unit C's first independent review found three consumer defects beyond the inventory's intended behavior; all are now fixed at routing/topology boundaries and the complete Go/TS/contract/Windows validation set is green.", + "raw": { + "focalPoints": [ + "consumer-inventory", + "legacy-topology", + "scoped-retry", + "cloud-only-workspaces" + ], + "adjustments": "Require the second exact-head local review to validate that the fixes closed the class before opening the stacked PR.", + "confidence": 0.87 + }, + "significance": "high", + "tags": [ + "focal:consumer-inventory", + "focal:legacy-topology", + "focal:scoped-retry", + "focal:cloud-only-workspaces", + "confidence:0.87" + ] + } + ] + } + ], + "retrospective": { + "summary": "Implemented Unit C operator surfaces for scoped multi-path mounts, then fixed independent-review findings in legacy topology recovery, catalog compatibility retry routing, child-state precedence, and cloud-only disconnect planning.", + "approach": "Standard approach", + "confidence": 0.88 + }, + "commits": [ + "8293a6f", + "73ee96d", + "7259b13", + "2b665ae", + "7f1271e", + "7d3f439", + "99970e6", + "c97a789", + "da4ce69", + "0e6711b" + ], + "filesChanged": [ + ".trajectories/active/traj_krf3a8bxjpku.json", + ".trajectories/completed/2026-07/traj_1kmlyo9fwsnv.json", + ".trajectories/completed/2026-07/traj_1kmlyo9fwsnv.md", + ".trajectories/completed/2026-07/traj_29k09yk5t2x1.json", + ".trajectories/completed/2026-07/traj_5qk62q12kolh.json", + ".trajectories/completed/2026-07/traj_5qk62q12kolh.md", + ".trajectories/completed/2026-07/traj_67s301119cc2.json", + ".trajectories/completed/2026-07/traj_8yty3dklbonk.json", + ".trajectories/completed/2026-07/traj_9iljc1uad7l1.json", + ".trajectories/completed/2026-07/traj_9iljc1uad7l1.md", + ".trajectories/completed/2026-07/traj_ewsm04d3yriw.json", + ".trajectories/completed/2026-07/traj_ewsm04d3yriw.md", + ".trajectories/completed/2026-07/traj_jj88zowb0k05.json", + ".trajectories/completed/2026-07/traj_kd7iuol8b4d2.json", + ".trajectories/completed/2026-07/traj_kd7iuol8b4d2.md", + ".trajectories/completed/2026-07/traj_nb6cq7m894kp.json", + ".trajectories/completed/2026-07/traj_rw26ij8x9yhr.json", + ".trajectories/completed/2026-07/traj_tdew3mg9dnit.json", + ".trajectories/completed/2026-07/traj_u1ub8ps9wt1x/trajectory.json", + ".trajectories/completed/2026-07/traj_uuzgy4neo8fh.json", + ".trajectories/completed/2026-07/traj_uymnrxehl9bf.json", + ".trajectories/completed/2026-07/traj_uymnrxehl9bf.md", + ".trajectories/completed/2026-07/traj_vbh4sg9b9wle.json", + ".trajectories/completed/2026-07/traj_vbh4sg9b9wle.md", + ".trajectories/completed/2026-07/traj_vo9bz6cff5fm.json", + ".trajectories/completed/2026-07/traj_vo9bz6cff5fm.md", + ".trajectories/completed/2026-07/traj_ybwfsutm6tzl.json", + ".trajectories/completed/2026-07/traj_ybwfsutm6tzl.md", + ".trajectories/completed/2026-07/traj_zx3ypuzq5t1j.json", + ".trajectories/completed/2026-07/traj_zx3ypuzq5t1j.md", + ".trajectories/index.json", + "README.md", + "cmd/relayfile-cli/background_test.go", + "cmd/relayfile-cli/filelock_other.go", + "cmd/relayfile-cli/filelock_unix.go", + "cmd/relayfile-cli/filelock_windows.go", + "cmd/relayfile-cli/main.go", + "cmd/relayfile-cli/main_test.go", + "cmd/relayfile-cli/writeback_list.go", + "cmd/relayfile-cli/writeback_list_test.go", + "cmd/relayfile-mount/main.go", + "cmd/relayfile-mount/main_test.go", + "go.mod", + "internal/mountscope/scope.go", + "internal/mountscope/scope_test.go", + "internal/mountsync/paths.go", + "internal/mountsync/paths_test.go", + "internal/mountsync/syncer.go", + "internal/mountsync/syncer_test.go", + "internal/mountsync/watcher.go", + "internal/mountsync/watcher_test.go", + "packages/sdk/typescript/src/setup-errors.ts" + ], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "e35eeb56963152665b592c4f8541239639db5159", + "endRef": "8293a6f7b72dae60d575880e75178648cd369376", + "traceId": "bc892cf0-1c09-449e-a7a4-ddeb2d74280c" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_krf3a8bxjpku.md b/.trajectories/completed/2026-07/traj_krf3a8bxjpku.md new file mode 100644 index 00000000..8ab13535 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_krf3a8bxjpku.md @@ -0,0 +1,45 @@ +# Trajectory: Finish scoped multi-path operator surfaces + +> **Status:** ✅ Completed +> **Task:** relayfile#379 / Unit C +> **Confidence:** 88% +> **Started:** July 30, 2026 at 02:43 AM +> **Completed:** July 30, 2026 at 06:13 AM + +--- + +## Summary + +Implemented Unit C operator surfaces for scoped multi-path mounts, then fixed independent-review findings in legacy topology recovery, catalog compatibility retry routing, child-state precedence, and cloud-only disconnect planning. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Represent missing exact remote-root state as unknown instead of silently defaulting to workspace root +- **Chose:** Represent missing exact remote-root state as unknown instead of silently defaulting to workspace root +- **Reasoning:** A singular child root is truthful only when persisted topology or the child snapshot supplies it. Falling back to / converts absent state into an unscoped claim and can misroute pending writeback operations; operator surfaces now refuse when the legacy exact root cannot be established. + +### Closed all three Unit C review findings at their trust boundaries +- **Chose:** Closed all three Unit C review findings at their trust boundaries +- **Reasoning:** Cloud-only disconnect now treats missing LocalDir as no local cleanup; legacy exact records recover their child root from persisted runtime state; scoped retry resolves catalog compatibility records to the matching child and child records take precedence over stale catalog duplicates. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Represent missing exact remote-root state as unknown instead of silently defaulting to workspace root: Represent missing exact remote-root state as unknown instead of silently defaulting to workspace root +- Closed all three Unit C review findings at their trust boundaries: Closed all three Unit C review findings at their trust boundaries +- Unit C's first independent review found three consumer defects beyond the inventory's intended behavior; all are now fixed at routing/topology boundaries and the complete Go/TS/contract/Windows validation set is green. + +--- + +## Artifacts + +**Commits:** 8293a6f, 73ee96d, 7259b13, 2b665ae, 7f1271e, 7d3f439, 99970e6, c97a789, da4ce69, 0e6711b +**Files changed:** 52 diff --git a/.trajectories/completed/2026-07/traj_krf3a8bxjpku.trace.json b/.trajectories/completed/2026-07/traj_krf3a8bxjpku.trace.json new file mode 100644 index 00000000..e207ad53 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_krf3a8bxjpku.trace.json @@ -0,0 +1,1407 @@ +{ + "version": "1.0.0", + "id": "bc892cf0-1c09-449e-a7a4-ddeb2d74280c", + "timestamp": "2026-07-30T10:13:35.224Z", + "trajectory": "traj_krf3a8bxjpku", + "files": [ + { + "path": ".trajectories/active/traj_krf3a8bxjpku.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 50, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_1kmlyo9fwsnv.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 186, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_1kmlyo9fwsnv.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 78, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_29k09yk5t2x1.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 60, + "end_line": 69, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_5qk62q12kolh.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 29, + "end_line": 40, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 104, + "end_line": 113, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_5qk62q12kolh.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 18, + "end_line": 26, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_67s301119cc2.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 48, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_8yty3dklbonk.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 48, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_9iljc1uad7l1.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_9iljc1uad7l1.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 32, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_ewsm04d3yriw.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_ewsm04d3yriw.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 32, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_jj88zowb0k05.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 48, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_kd7iuol8b4d2.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 117, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_kd7iuol8b4d2.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_nb6cq7m894kp.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 48, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_rw26ij8x9yhr.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 48, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_tdew3mg9dnit.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 48, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_u1ub8ps9wt1x/trajectory.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 65, + "end_line": 74, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_uuzgy4neo8fh.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 48, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_uymnrxehl9bf.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 101, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_uymnrxehl9bf.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 43, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_vbh4sg9b9wle.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_vbh4sg9b9wle.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 32, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_vo9bz6cff5fm.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_vo9bz6cff5fm.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 32, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_ybwfsutm6tzl.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 69, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_ybwfsutm6tzl.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 37, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_zx3ypuzq5t1j.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 57, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/completed/2026-07/traj_zx3ypuzq5t1j.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 32, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": ".trajectories/index.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 6, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 148, + "end_line": 222, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "README.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 228, + "end_line": 260, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/background_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 8, + "end_line": 21, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 119, + "end_line": 195, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 537, + "end_line": 553, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 567, + "end_line": 600, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/filelock_other.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 4, + "end_line": 21, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/filelock_unix.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 4, + "end_line": 26, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 29, + "end_line": 44, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/filelock_windows.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 8, + "end_line": 21, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 26, + "end_line": 50, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/main.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 22, + "end_line": 28, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 1065, + "end_line": 1079, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 1932, + "end_line": 1942, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 1958, + "end_line": 1970, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 1982, + "end_line": 2096, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4019, + "end_line": 4037, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4046, + "end_line": 4052, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4059, + "end_line": 4069, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4076, + "end_line": 4082, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4084, + "end_line": 4090, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4092, + "end_line": 4129, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4132, + "end_line": 4218, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4228, + "end_line": 4237, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4266, + "end_line": 4295, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4381, + "end_line": 4549, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4980, + "end_line": 4995, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 5413, + "end_line": 5425, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 5744, + "end_line": 5750, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 5777, + "end_line": 5782, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 5809, + "end_line": 5814, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6694, + "end_line": 6702, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6771, + "end_line": 6790, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6800, + "end_line": 6824, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6826, + "end_line": 6832, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6862, + "end_line": 6888, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6933, + "end_line": 6939, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6952, + "end_line": 6970, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6998, + "end_line": 7004, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 7020, + "end_line": 7030, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 7070, + "end_line": 7076, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 7112, + "end_line": 7183, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 11181, + "end_line": 11190, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 11522, + "end_line": 11535, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 11596, + "end_line": 11603, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 13117, + "end_line": 13131, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 13142, + "end_line": 13230, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 13549, + "end_line": 13555, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/main_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 2390, + "end_line": 2398, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 2436, + "end_line": 2509, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 2532, + "end_line": 2538, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 2561, + "end_line": 2646, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 2678, + "end_line": 2684, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 2686, + "end_line": 2831, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 2845, + "end_line": 2887, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 2924, + "end_line": 2930, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 2947, + "end_line": 3115, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 3132, + "end_line": 3151, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 3411, + "end_line": 3471, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/writeback_list.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 11, + "end_line": 18, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 86, + "end_line": 102, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 136, + "end_line": 142, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 157, + "end_line": 163, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 210, + "end_line": 216, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 222, + "end_line": 233, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 262, + "end_line": 272, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/writeback_list_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 223, + "end_line": 230, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 239, + "end_line": 258, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 261, + "end_line": 267, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 282, + "end_line": 354, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 593, + "end_line": 611, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-mount/main.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 65, + "end_line": 71, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 145, + "end_line": 153, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 275, + "end_line": 286, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 303, + "end_line": 321, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 351, + "end_line": 380, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 394, + "end_line": 400, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 479, + "end_line": 485, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-mount/main_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 630, + "end_line": 657, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "go.mod", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 9, + "end_line": 15, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "internal/mountscope/scope.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 2, + "end_line": 86, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 206, + "end_line": 256, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 296, + "end_line": 303, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 307, + "end_line": 322, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 324, + "end_line": 345, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 349, + "end_line": 556, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "internal/mountscope/scope_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 7, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 16, + "end_line": 28, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 76, + "end_line": 82, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 92, + "end_line": 233, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 270, + "end_line": 305, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "internal/mountsync/paths.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 113, + "end_line": 132, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "internal/mountsync/paths_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 110, + "end_line": 130, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "internal/mountsync/syncer.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 33, + "end_line": 39, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 886, + "end_line": 902, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 1085, + "end_line": 1091, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 1232, + "end_line": 1240, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 1797, + "end_line": 1803, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 1830, + "end_line": 1839, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 1850, + "end_line": 1861, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 2056, + "end_line": 2065, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 4322, + "end_line": 4331, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 5988, + "end_line": 6000, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6004, + "end_line": 6009, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6019, + "end_line": 6024, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6227, + "end_line": 6236, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6246, + "end_line": 6252, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6527, + "end_line": 6558, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6855, + "end_line": 6861, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6877, + "end_line": 6928, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 6944, + "end_line": 6952, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 7801, + "end_line": 7807, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 8098, + "end_line": 8104, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "internal/mountsync/syncer_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 28, + "end_line": 34, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 172, + "end_line": 412, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "internal/mountsync/watcher.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 10, + "end_line": 16, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 23, + "end_line": 30, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 35, + "end_line": 68, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 140, + "end_line": 174, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 213, + "end_line": 220, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + }, + { + "start_line": 287, + "end_line": 293, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "internal/mountsync/watcher_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 18, + "end_line": 85, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/setup-errors.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 116, + "end_line": 122, + "revision": "8293a6f7b72dae60d575880e75178648cd369376" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_kya5brhilqxi.json b/.trajectories/completed/2026-07/traj_kya5brhilqxi.json new file mode 100644 index 00000000..b12c6de3 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_kya5brhilqxi.json @@ -0,0 +1,53 @@ +{ + "id": "traj_kya5brhilqxi", + "version": 1, + "task": { + "title": "Review changes against fix/scoped-multipath-cli-runtime" + }, + "status": "completed", + "startedAt": "2026-07-30T11:30:11.161Z", + "completedAt": "2026-07-30T11:32:34.683Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T11:32:34.348Z" + } + ], + "chapters": [ + { + "id": "chap_0ajq2i0qn6p6", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T11:32:34.348Z", + "endedAt": "2026-07-30T11:32:34.683Z", + "events": [ + { + "ts": 1785411154348, + "type": "decision", + "content": "Focused review on scoped operator aggregation, dead-letter routing, and disconnect safety; no source edits: Focused review on scoped operator aggregation, dead-letter routing, and disconnect safety; no source edits", + "raw": { + "question": "Focused review on scoped operator aggregation, dead-letter routing, and disconnect safety; no source edits", + "chosen": "Focused review on scoped operator aggregation, dead-letter routing, and disconnect safety; no source edits", + "alternatives": [], + "reasoning": "These are the functional surfaces changed by the patch" + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Reviewed scoped multipath operator-surface changes and identified a disconnect post-mutation race", + "approach": "Standard approach", + "confidence": 0.82 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "6fdd016d1aedb3fffd69ea13d5c4f5c4a42fe9ff", + "endRef": "6fdd016d1aedb3fffd69ea13d5c4f5c4a42fe9ff" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_kya5brhilqxi.md b/.trajectories/completed/2026-07/traj_kya5brhilqxi.md new file mode 100644 index 00000000..3fcc61f1 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_kya5brhilqxi.md @@ -0,0 +1,31 @@ +# Trajectory: Review changes against fix/scoped-multipath-cli-runtime + +> **Status:** ✅ Completed +> **Confidence:** 82% +> **Started:** July 30, 2026 at 07:30 AM +> **Completed:** July 30, 2026 at 07:32 AM + +--- + +## Summary + +Reviewed scoped multipath operator-surface changes and identified a disconnect post-mutation race + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Focused review on scoped operator aggregation, dead-letter routing, and disconnect safety; no source edits +- **Chose:** Focused review on scoped operator aggregation, dead-letter routing, and disconnect safety; no source edits +- **Reasoning:** These are the functional surfaces changed by the patch + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Focused review on scoped operator aggregation, dead-letter routing, and disconnect safety; no source edits: Focused review on scoped operator aggregation, dead-letter routing, and disconnect safety; no source edits diff --git a/.trajectories/completed/2026-07/traj_mdp5m449wxp3.json b/.trajectories/completed/2026-07/traj_mdp5m449wxp3.json new file mode 100644 index 00000000..e65547a5 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_mdp5m449wxp3.json @@ -0,0 +1,53 @@ +{ + "id": "traj_mdp5m449wxp3", + "version": 1, + "task": { + "title": "Review changes against fix/scoped-multipath-cli-runtime" + }, + "status": "completed", + "startedAt": "2026-07-30T10:27:28.401Z", + "completedAt": "2026-07-30T10:31:08.607Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T10:31:08.256Z" + } + ], + "chapters": [ + { + "id": "chap_66vned07u1ht", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T10:31:08.256Z", + "endedAt": "2026-07-30T10:31:08.607Z", + "events": [ + { + "ts": 1785407468257, + "type": "decision", + "content": "Flag mixed-provider bulk dead-letter filtering during disconnect: Flag mixed-provider bulk dead-letter filtering during disconnect", + "raw": { + "question": "Flag mixed-provider bulk dead-letter filtering during disconnect", + "chosen": "Flag mixed-provider bulk dead-letter filtering during disconnect", + "alternatives": [], + "reasoning": "Bulk failure records join paths with commas; provider preflight treats the joined value as one path, so a provider appearing after the first path is not detected and its mirror can be deleted despite pending dead-letter work." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Reviewed scoped multi-path CLI operator changes; identified provider disconnect data-loss gap for mixed-provider bulk dead letters.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "a5f18dca2818458eb1ed169aafff9339f2ff5ff9", + "endRef": "a5f18dca2818458eb1ed169aafff9339f2ff5ff9" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_mdp5m449wxp3.md b/.trajectories/completed/2026-07/traj_mdp5m449wxp3.md new file mode 100644 index 00000000..fe657e91 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_mdp5m449wxp3.md @@ -0,0 +1,31 @@ +# Trajectory: Review changes against fix/scoped-multipath-cli-runtime + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** July 30, 2026 at 06:27 AM +> **Completed:** July 30, 2026 at 06:31 AM + +--- + +## Summary + +Reviewed scoped multi-path CLI operator changes; identified provider disconnect data-loss gap for mixed-provider bulk dead letters. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Flag mixed-provider bulk dead-letter filtering during disconnect +- **Chose:** Flag mixed-provider bulk dead-letter filtering during disconnect +- **Reasoning:** Bulk failure records join paths with commas; provider preflight treats the joined value as one path, so a provider appearing after the first path is not detected and its mirror can be deleted despite pending dead-letter work. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Flag mixed-provider bulk dead-letter filtering during disconnect: Flag mixed-provider bulk dead-letter filtering during disconnect diff --git a/.trajectories/completed/2026-07/traj_ndv3lptktys4.json b/.trajectories/completed/2026-07/traj_ndv3lptktys4.json new file mode 100644 index 00000000..c3fabeb6 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_ndv3lptktys4.json @@ -0,0 +1,53 @@ +{ + "id": "traj_ndv3lptktys4", + "version": 1, + "task": { + "title": "Review changes against fix/scoped-multipath-cli-runtime" + }, + "status": "completed", + "startedAt": "2026-07-30T11:19:58.851Z", + "completedAt": "2026-07-30T11:20:08.227Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T11:20:07.888Z" + } + ], + "chapters": [ + { + "id": "chap_hpvk5ap0d6bf", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T11:20:07.888Z", + "endedAt": "2026-07-30T11:20:08.227Z", + "events": [ + { + "ts": 1785410407888, + "type": "reflection", + "content": "Reviewed scoped operator routing, private state aggregation, dead-letter retry, and disconnect safety; no actionable regression identified", + "raw": { + "confidence": 0.82 + }, + "significance": "high", + "tags": [ + "confidence:0.82" + ] + } + ] + } + ], + "retrospective": { + "summary": "Completed code review against merge base; no findings", + "approach": "Standard approach", + "confidence": 0.82 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "59e5f4ab21a094092801449bd33ce573b2f360b8", + "endRef": "59e5f4ab21a094092801449bd33ce573b2f360b8" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_ndv3lptktys4.md b/.trajectories/completed/2026-07/traj_ndv3lptktys4.md new file mode 100644 index 00000000..ca8578de --- /dev/null +++ b/.trajectories/completed/2026-07/traj_ndv3lptktys4.md @@ -0,0 +1,23 @@ +# Trajectory: Review changes against fix/scoped-multipath-cli-runtime + +> **Status:** ✅ Completed +> **Confidence:** 82% +> **Started:** July 30, 2026 at 07:19 AM +> **Completed:** July 30, 2026 at 07:20 AM + +--- + +## Summary + +Completed code review against merge base; no findings + +**Approach:** Standard approach + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Reviewed scoped operator routing, private state aggregation, dead-letter retry, and disconnect safety; no actionable regression identified diff --git a/.trajectories/completed/2026-07/traj_qgllp1idox35.json b/.trajectories/completed/2026-07/traj_qgllp1idox35.json new file mode 100644 index 00000000..1d1f16f4 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_qgllp1idox35.json @@ -0,0 +1,69 @@ +{ + "id": "traj_qgllp1idox35", + "version": 1, + "task": { + "title": "Preserve local infrastructure and centralize cross-scope dead letters", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T10:58:04.858Z", + "completedAt": "2026-07-30T11:00:10.991Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T10:58:05.206Z" + } + ], + "chapters": [ + { + "id": "chap_vjex5y4dt8ef", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T10:58:05.206Z", + "endedAt": "2026-07-30T11:00:10.991Z", + "events": [ + { + "ts": 1785409085207, + "type": "decision", + "content": "Store cross-scope dead-letter records at the catalog root: Store cross-scope dead-letter records at the catalog root", + "raw": { + "question": "Store cross-scope dead-letter records at the catalog root", + "chosen": "Store cross-scope dead-letter records at the catalog root", + "alternatives": [], + "reasoning": "A sibling provider's disconnect preflight cannot discover a bulk record stored only under the first child's runtime root." + }, + "significance": "high" + }, + { + "ts": 1785409085535, + "type": "decision", + "content": "Preserve mount-excluded infrastructure during scoped disconnect: Preserve mount-excluded infrastructure during scoped disconnect", + "raw": { + "question": "Preserve mount-excluded infrastructure during scoped disconnect", + "chosen": "Preserve mount-excluded infrastructure during scoped disconnect", + "alternatives": [], + "reasoning": "Entries such as .git are intentionally outside mount synchronization and therefore cannot be deleted as mirrored provider content." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Centralized multi-root dead letters at the catalog root and preserved mount-excluded infrastructure during scoped disconnect cleanup.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "c2fdcc6bf68d6f0101e8d0df91985210fce911b4", + "endRef": "c2fdcc6bf68d6f0101e8d0df91985210fce911b4" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_qgllp1idox35.md b/.trajectories/completed/2026-07/traj_qgllp1idox35.md new file mode 100644 index 00000000..2ec33d46 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_qgllp1idox35.md @@ -0,0 +1,37 @@ +# Trajectory: Preserve local infrastructure and centralize cross-scope dead letters + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 90% +> **Started:** July 30, 2026 at 06:58 AM +> **Completed:** July 30, 2026 at 07:00 AM + +--- + +## Summary + +Centralized multi-root dead letters at the catalog root and preserved mount-excluded infrastructure during scoped disconnect cleanup. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Store cross-scope dead-letter records at the catalog root +- **Chose:** Store cross-scope dead-letter records at the catalog root +- **Reasoning:** A sibling provider's disconnect preflight cannot discover a bulk record stored only under the first child's runtime root. + +### Preserve mount-excluded infrastructure during scoped disconnect +- **Chose:** Preserve mount-excluded infrastructure during scoped disconnect +- **Reasoning:** Entries such as .git are intentionally outside mount synchronization and therefore cannot be deleted as mirrored provider content. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Store cross-scope dead-letter records at the catalog root: Store cross-scope dead-letter records at the catalog root +- Preserve mount-excluded infrastructure during scoped disconnect: Preserve mount-excluded infrastructure during scoped disconnect diff --git a/.trajectories/completed/2026-07/traj_qrdorshw6pc2.json b/.trajectories/completed/2026-07/traj_qrdorshw6pc2.json new file mode 100644 index 00000000..c3a43d5d --- /dev/null +++ b/.trajectories/completed/2026-07/traj_qrdorshw6pc2.json @@ -0,0 +1,57 @@ +{ + "id": "traj_qrdorshw6pc2", + "version": 1, + "task": { + "title": "Carry disconnect preflight across the Cloud commit boundary", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T11:34:07.140Z", + "completedAt": "2026-07-30T11:36:20.247Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T11:34:07.518Z" + } + ], + "chapters": [ + { + "id": "chap_cko07w4dqno4", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T11:34:07.518Z", + "endedAt": "2026-07-30T11:36:20.247Z", + "events": [ + { + "ts": 1785411247519, + "type": "decision", + "content": "Pass one validated cleanup plan through Cloud DELETE: Pass one validated cleanup plan through Cloud DELETE", + "raw": { + "question": "Pass one validated cleanup plan through Cloud DELETE", + "chosen": "Pass one validated cleanup plan through Cloud DELETE", + "alternatives": [], + "reasoning": "Re-running a mutable refusal after an irreversible external mutation can return failure with Cloud disconnected and local state still connected-looking." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Passed one validated provider-disconnect cleanup plan across the Cloud DELETE boundary and pinned both pre- and post-mutation outcomes", + "approach": "Standard approach", + "confidence": 0.92 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "6fdd016d1aedb3fffd69ea13d5c4f5c4a42fe9ff", + "endRef": "6fdd016d1aedb3fffd69ea13d5c4f5c4a42fe9ff" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_qrdorshw6pc2.md b/.trajectories/completed/2026-07/traj_qrdorshw6pc2.md new file mode 100644 index 00000000..ec078d82 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_qrdorshw6pc2.md @@ -0,0 +1,32 @@ +# Trajectory: Carry disconnect preflight across the Cloud commit boundary + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 92% +> **Started:** July 30, 2026 at 07:34 AM +> **Completed:** July 30, 2026 at 07:36 AM + +--- + +## Summary + +Passed one validated provider-disconnect cleanup plan across the Cloud DELETE boundary and pinned both pre- and post-mutation outcomes + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Pass one validated cleanup plan through Cloud DELETE +- **Chose:** Pass one validated cleanup plan through Cloud DELETE +- **Reasoning:** Re-running a mutable refusal after an irreversible external mutation can return failure with Cloud disconnected and local state still connected-looking. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Pass one validated cleanup plan through Cloud DELETE: Pass one validated cleanup plan through Cloud DELETE diff --git a/.trajectories/completed/2026-07/traj_s57bw9graew3.json b/.trajectories/completed/2026-07/traj_s57bw9graew3.json new file mode 100644 index 00000000..b47a2aae --- /dev/null +++ b/.trajectories/completed/2026-07/traj_s57bw9graew3.json @@ -0,0 +1,57 @@ +{ + "id": "traj_s57bw9graew3", + "version": 1, + "task": { + "title": "Refuse integration disconnect with unsynced scoped state", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T06:05:24.821Z", + "completedAt": "2026-07-30T06:07:48.718Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T06:07:48.652Z" + } + ], + "chapters": [ + { + "id": "chap_2vpynmsqldrt", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T06:07:48.652Z", + "endedAt": "2026-07-30T06:07:48.718Z", + "events": [ + { + "ts": 1785391668653, + "type": "decision", + "content": "Refuse disconnect before Cloud mutation when local work is pending: Refuse disconnect before Cloud mutation when local work is pending", + "raw": { + "question": "Refuse disconnect before Cloud mutation when local work is pending", + "chosen": "Refuse disconnect before Cloud mutation when local work is pending", + "alternatives": [], + "reasoning": "Conflicts, dead letters, and pending/failed/attention outbox records are the only copies of work not accepted remotely. The preflight must run before the Cloud DELETE and again before local cleanup; it reports counts and preserves every file on refusal. Active or unverified mounts also refuse to close the check-delete race." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Made integration disconnect preflight all provider-owned runtime roots before Cloud mutation, refuse active mounts and counted unsynced state, and preserve all mirror/runtime files on refusal. Focused disconnect and inherited case-fold boundary tests pass.", + "approach": "Standard approach", + "confidence": 0.93 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "dfc3bf37e2f2e9719174cff3aab1d2e05f6f07d2", + "endRef": "dfc3bf37e2f2e9719174cff3aab1d2e05f6f07d2" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_s57bw9graew3.md b/.trajectories/completed/2026-07/traj_s57bw9graew3.md new file mode 100644 index 00000000..28c088fd --- /dev/null +++ b/.trajectories/completed/2026-07/traj_s57bw9graew3.md @@ -0,0 +1,32 @@ +# Trajectory: Refuse integration disconnect with unsynced scoped state + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 93% +> **Started:** July 30, 2026 at 02:05 AM +> **Completed:** July 30, 2026 at 02:07 AM + +--- + +## Summary + +Made integration disconnect preflight all provider-owned runtime roots before Cloud mutation, refuse active mounts and counted unsynced state, and preserve all mirror/runtime files on refusal. Focused disconnect and inherited case-fold boundary tests pass. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Refuse disconnect before Cloud mutation when local work is pending +- **Chose:** Refuse disconnect before Cloud mutation when local work is pending +- **Reasoning:** Conflicts, dead letters, and pending/failed/attention outbox records are the only copies of work not accepted remotely. The preflight must run before the Cloud DELETE and again before local cleanup; it reports counts and preserves every file on refusal. Active or unverified mounts also refuse to close the check-delete race. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Refuse disconnect before Cloud mutation when local work is pending: Refuse disconnect before Cloud mutation when local work is pending diff --git a/.trajectories/completed/2026-07/traj_unv64id5jv6z.json b/.trajectories/completed/2026-07/traj_unv64id5jv6z.json new file mode 100644 index 00000000..ffdf756f --- /dev/null +++ b/.trajectories/completed/2026-07/traj_unv64id5jv6z.json @@ -0,0 +1,57 @@ +{ + "id": "traj_unv64id5jv6z", + "version": 1, + "task": { + "title": "Route cross-scope dead-letter retries per scoped runtime root", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T10:39:35.159Z", + "completedAt": "2026-07-30T10:42:00.443Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T10:39:35.513Z" + } + ], + "chapters": [ + { + "id": "chap_nuuzmrtg4fni", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T10:39:35.513Z", + "endedAt": "2026-07-30T10:42:00.443Z", + "events": [ + { + "ts": 1785407975514, + "type": "decision", + "content": "Precompute and validate every retry group before invoking any Syncer: Precompute and validate every retry group before invoking any Syncer", + "raw": { + "question": "Precompute and validate every retry group before invoking any Syncer", + "chosen": "Precompute and validate every retry group before invoking any Syncer", + "alternatives": [], + "reasoning": "A comma-joined dead-letter can span scoped roots; selecting one Syncer makes later paths unretryable, while resolving the complete plan first prevents partial retries when any path is invalid." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Grouped comma-joined dead-letter paths by scoped runtime root, prevalidated the complete plan, and routed one Syncer retry per group with order-independent coverage.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "bbde8a17097f26acbcaa3227f6e2be6372c750c0", + "endRef": "bbde8a17097f26acbcaa3227f6e2be6372c750c0" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_unv64id5jv6z.md b/.trajectories/completed/2026-07/traj_unv64id5jv6z.md new file mode 100644 index 00000000..5f76dfaf --- /dev/null +++ b/.trajectories/completed/2026-07/traj_unv64id5jv6z.md @@ -0,0 +1,32 @@ +# Trajectory: Route cross-scope dead-letter retries per scoped runtime root + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 90% +> **Started:** July 30, 2026 at 06:39 AM +> **Completed:** July 30, 2026 at 06:42 AM + +--- + +## Summary + +Grouped comma-joined dead-letter paths by scoped runtime root, prevalidated the complete plan, and routed one Syncer retry per group with order-independent coverage. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Precompute and validate every retry group before invoking any Syncer +- **Chose:** Precompute and validate every retry group before invoking any Syncer +- **Reasoning:** A comma-joined dead-letter can span scoped roots; selecting one Syncer makes later paths unretryable, while resolving the complete plan first prevents partial retries when any path is invalid. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Precompute and validate every retry group before invoking any Syncer: Precompute and validate every retry group before invoking any Syncer diff --git a/.trajectories/completed/2026-07/traj_w7xxtfcagb89.json b/.trajectories/completed/2026-07/traj_w7xxtfcagb89.json new file mode 100644 index 00000000..9489320e --- /dev/null +++ b/.trajectories/completed/2026-07/traj_w7xxtfcagb89.json @@ -0,0 +1,57 @@ +{ + "id": "traj_w7xxtfcagb89", + "version": 1, + "task": { + "title": "Preserve exact root when runtime state is unavailable", + "source": { + "system": "plain", + "id": "relayfile#379" + } + }, + "status": "completed", + "startedAt": "2026-07-30T05:52:04.691Z", + "completedAt": "2026-07-30T05:52:32.927Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-30T05:52:32.864Z" + } + ], + "chapters": [ + { + "id": "chap_gltcywpy7cv8", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-30T05:52:32.864Z", + "endedAt": "2026-07-30T05:52:32.927Z", + "events": [ + { + "ts": 1785390752865, + "type": "decision", + "content": "Distinguish absent runtime topology from the root path: Distinguish absent runtime topology from the root path", + "raw": { + "question": "Distinguish absent runtime topology from the root path", + "chosen": "Distinguish absent runtime topology from the root path", + "alternatives": [], + "reasoning": "The legacy helper returns / for both a real root mount and missing/unreadable state. Aggregate exact status now uses a presence-aware read and falls back to the persisted exact root only when runtime state makes no claim." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Made exact aggregate status preserve the persisted non-root path when runtime state is absent or unreadable; focused topology tests pass.", + "approach": "Standard approach", + "confidence": 0.94 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c", + "tags": [], + "_trace": { + "startRef": "7019269642da19d3fc2fd90b2dab3b6b94644caf", + "endRef": "7019269642da19d3fc2fd90b2dab3b6b94644caf" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-07/traj_w7xxtfcagb89.md b/.trajectories/completed/2026-07/traj_w7xxtfcagb89.md new file mode 100644 index 00000000..3e96f146 --- /dev/null +++ b/.trajectories/completed/2026-07/traj_w7xxtfcagb89.md @@ -0,0 +1,32 @@ +# Trajectory: Preserve exact root when runtime state is unavailable + +> **Status:** ✅ Completed +> **Task:** relayfile#379 +> **Confidence:** 94% +> **Started:** July 30, 2026 at 01:52 AM +> **Completed:** July 30, 2026 at 01:52 AM + +--- + +## Summary + +Made exact aggregate status preserve the persisted non-root path when runtime state is absent or unreadable; focused topology tests pass. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Distinguish absent runtime topology from the root path +- **Chose:** Distinguish absent runtime topology from the root path +- **Reasoning:** The legacy helper returns / for both a real root mount and missing/unreadable state. Aggregate exact status now uses a presence-aware read and falls back to the persisted exact root only when runtime state makes no claim. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Distinguish absent runtime topology from the root path: Distinguish absent runtime topology from the root path diff --git a/.trajectories/index.json b/.trajectories/index.json index 0c47a1be..751cdf33 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -100,6 +100,62 @@ "completedAt": "2026-07-30T06:00:44.272Z", "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-b/.trajectories/completed/2026-07/traj_rw26ij8x9yhr.json" }, + "traj_3gdyy15i9c13": { + "title": "Aggregate scoped mount operator surfaces", + "status": "completed", + "startedAt": "2026-07-30T05:08:42.612Z", + "completedAt": "2026-07-30T05:16:54.894Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_3gdyy15i9c13.json" + }, + "traj_fs8p6efpfqtt": { + "title": "Resolve scoped operator state through persisted private identities", + "status": "completed", + "startedAt": "2026-07-30T05:32:48.408Z", + "completedAt": "2026-07-30T05:34:52.700Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_fs8p6efpfqtt.json" + }, + "traj_0rjz89laoif3": { + "title": "Read scoped writeback status from private state", + "status": "completed", + "startedAt": "2026-07-30T05:43:10.555Z", + "completedAt": "2026-07-30T05:43:18.024Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_0rjz89laoif3.json" + }, + "traj_w7xxtfcagb89": { + "title": "Preserve exact root when runtime state is unavailable", + "status": "completed", + "startedAt": "2026-07-30T05:52:04.691Z", + "completedAt": "2026-07-30T05:52:32.927Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_w7xxtfcagb89.json" + }, + "traj_0ivieo04w496": { + "title": "Preserve scoped runtime state on integration disconnect", + "status": "completed", + "startedAt": "2026-07-30T06:02:06.962Z", + "completedAt": "2026-07-30T06:02:43.614Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_0ivieo04w496.json" + }, + "traj_s57bw9graew3": { + "title": "Refuse integration disconnect with unsynced scoped state", + "status": "completed", + "startedAt": "2026-07-30T06:05:24.821Z", + "completedAt": "2026-07-30T06:07:48.718Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_s57bw9graew3.json" + }, + "traj_gch0k1mre9do": { + "title": "Address Unit C exact review findings", + "status": "completed", + "startedAt": "2026-07-30T06:17:45.263Z", + "completedAt": "2026-07-30T06:20:10.418Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_gch0k1mre9do.json" + }, + "traj_krf3a8bxjpku": { + "title": "Finish scoped multi-path operator surfaces", + "status": "completed", + "startedAt": "2026-07-30T06:43:30.836Z", + "completedAt": "2026-07-30T10:13:35.117Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_krf3a8bxjpku.json" + }, "traj_uymnrxehl9bf": { "title": "Address Unit B exact GitHub review findings", "status": "completed", @@ -163,6 +219,132 @@ "completedAt": "2026-07-30T09:58:24.657Z", "path": ".trajectories/completed/2026-07/traj_1kmlyo9fwsnv.json" }, + "traj_2a93tcgvb68h": { + "title": "Review changes against fix/scoped-multipath-cli-runtime", + "status": "completed", + "startedAt": "2026-07-30T10:13:57.008Z", + "completedAt": "2026-07-30T10:16:19.338Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_2a93tcgvb68h.json" + }, + "traj_3dm6nse5csdk": { + "title": "Close Unit C second-review disconnect findings", + "status": "completed", + "startedAt": "2026-07-30T10:17:52.747Z", + "completedAt": "2026-07-30T10:27:07.565Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_3dm6nse5csdk.json" + }, + "traj_mdp5m449wxp3": { + "title": "Review changes against fix/scoped-multipath-cli-runtime", + "status": "completed", + "startedAt": "2026-07-30T10:27:28.401Z", + "completedAt": "2026-07-30T10:31:08.607Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_mdp5m449wxp3.json" + }, + "traj_bua53wamitju": { + "title": "Review changes against fix/scoped-multipath-cli-runtime", + "status": "completed", + "startedAt": "2026-07-30T10:34:12.908Z", + "completedAt": "2026-07-30T10:37:12.687Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_bua53wamitju.json" + }, + "traj_unv64id5jv6z": { + "title": "Route cross-scope dead-letter retries per scoped runtime root", + "status": "completed", + "startedAt": "2026-07-30T10:39:35.159Z", + "completedAt": "2026-07-30T10:42:00.443Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_unv64id5jv6z.json" + }, + "traj_k2nocx979usx": { + "title": "Review code changes against fix/scoped-multipath-cli-runtime", + "status": "completed", + "startedAt": "2026-07-30T10:42:20.534Z", + "completedAt": "2026-07-30T10:45:37.827Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_k2nocx979usx.json" + }, + "traj_0kko9s6utmhy": { + "title": "Close exact-root and scoped-disconnect review findings", + "status": "completed", + "startedAt": "2026-07-30T10:46:44.245Z", + "completedAt": "2026-07-30T10:48:54.739Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_0kko9s6utmhy.json" + }, + "traj_86dy5h7bjo8i": { + "title": "Review changes against fix/scoped-multipath-cli-runtime", + "status": "completed", + "startedAt": "2026-07-30T10:49:13.217Z", + "completedAt": "2026-07-30T10:51:23.530Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_86dy5h7bjo8i.json" + }, + "traj_1sp08l8m1pe4": { + "title": "Make retry and health independent of compatibility storage root", + "status": "completed", + "startedAt": "2026-07-30T10:52:38.466Z", + "completedAt": "2026-07-30T10:54:45.729Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_1sp08l8m1pe4.json" + }, + "traj_31gi21o8cioq": { + "title": "Review changes against fix/scoped-multipath-cli-runtime", + "status": "completed", + "startedAt": "2026-07-30T10:55:05.524Z", + "completedAt": "2026-07-30T10:57:00.921Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_31gi21o8cioq.json" + }, + "traj_qgllp1idox35": { + "title": "Preserve local infrastructure and centralize cross-scope dead letters", + "status": "completed", + "startedAt": "2026-07-30T10:58:04.858Z", + "completedAt": "2026-07-30T11:00:10.991Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_qgllp1idox35.json" + }, + "traj_a4qloqpqajl3": { + "title": "Review changes against fix/scoped-multipath-cli-runtime", + "status": "abandoned", + "startedAt": "2026-07-30T11:00:27.036Z", + "completedAt": "2026-07-30T11:19:46.819Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_a4qloqpqajl3.json" + }, + "traj_ndv3lptktys4": { + "title": "Review changes against fix/scoped-multipath-cli-runtime", + "status": "completed", + "startedAt": "2026-07-30T11:19:58.851Z", + "completedAt": "2026-07-30T11:20:08.227Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_ndv3lptktys4.json" + }, + "traj_dowdovihdusp": { + "title": "Keep delete-pending writebacks visible", + "status": "completed", + "startedAt": "2026-07-30T11:23:34.612Z", + "completedAt": "2026-07-30T11:29:40.251Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_dowdovihdusp.json" + }, + "traj_kya5brhilqxi": { + "title": "Review changes against fix/scoped-multipath-cli-runtime", + "status": "completed", + "startedAt": "2026-07-30T11:30:11.161Z", + "completedAt": "2026-07-30T11:32:34.683Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_kya5brhilqxi.json" + }, + "traj_qrdorshw6pc2": { + "title": "Carry disconnect preflight across the Cloud commit boundary", + "status": "completed", + "startedAt": "2026-07-30T11:34:07.140Z", + "completedAt": "2026-07-30T11:36:20.247Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_qrdorshw6pc2.json" + }, + "traj_heylf6s1ddr1": { + "title": "Review changes against fix/scoped-multipath-cli-runtime", + "status": "completed", + "startedAt": "2026-07-30T11:36:43.414Z", + "completedAt": "2026-07-30T11:39:12.593Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_heylf6s1ddr1.json" + }, + "traj_juhkgunm7iz9": { + "title": "Refuse disconnect on unobserved local drift", + "status": "completed", + "startedAt": "2026-07-30T11:40:53.561Z", + "completedAt": "2026-07-30T12:33:29.593Z", + "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-c/.trajectories/completed/2026-07/traj_juhkgunm7iz9.json" + }, "traj_scyyoykk4ura": { "title": "Close scoped catalog cleanup TOCTOU", "status": "completed", @@ -185,4 +367,4 @@ "path": "/Users/will/Projects/AgentWorkforce/relayfile-wt-379-b/.trajectories/completed/2026-07/traj_fksma6kiqy93.json" } } -} \ No newline at end of file +} diff --git a/README.md b/README.md index f45a724d..26b35fa1 100644 --- a/README.md +++ b/README.md @@ -198,22 +198,25 @@ RELAYFILE_TOKEN="$TOKEN" go run ./cmd/relayfile-mount \ --local-dir ./relayfile-mount ``` -Multi-path/scoped mounts are temporarily unavailable in both mount binaries -until the status, list, and retry surfaces can enumerate every scoped child -runtime directory. The binaries refuse `--local-layout scoped` (including -values inherited from `RELAYFILE_MOUNT_LOCAL_LAYOUT` or a recorded catalog -entry); use the exact layout for now. The scoped capability and its multi-path -examples will return here when that operator-surface contract is complete. - -When re-enabled, Relayfile will persist the allowlist and layout for later -starts, so omitting the flags does not widen a scoped mount back to `/`. It will -refuse in-place layout changes and removal of scoped roots because those -transitions require moving runtime state and queued writes. Choose a new -`LOCAL_DIR`; pass `--rehome` when that choice changes the workspace's registered -mirror directory. Records created before layout persistence that already have -local mount state must use that same new-directory migration before enabling -scoped mounts. A setup-created record that has never mounted has no state to -orphan and may start scoped in its chosen directory. +Use a scoped layout to mount more than one allowlisted remote root beneath a +single catalog directory: + +```bash +relayfile mount my-workspace ./relayfile-mount \ + --local-layout scoped \ + --remote-path /github \ + --remote-path /slack +``` + +Relayfile persists the allowlist and layout for later starts, so omitting the +flags does not widen a scoped mount back to `/`. It refuses in-place layout +changes and removal of scoped roots because those transitions require moving +runtime state and queued writes. Choose a new `LOCAL_DIR`; pass `--rehome` when +that choice changes the workspace's registered mirror directory. Records +created before layout persistence that already have local mount state must use +that same new-directory migration before enabling scoped mounts. A setup-created +record that has never mounted has no state to orphan and may start scoped in its +chosen directory. Mount startup visibly lists incidental source-control infrastructure that is excluded from sync (`.git`, `.hg`, `.svn`, `.bzr`, `_darcs`, and `.jj`). These diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index bcc59d09..89efbc54 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -405,6 +405,48 @@ type syncStateFile struct { Bootstrap *syncStateBootstrap `json:"bootstrap,omitempty"` } +// Aggregate status has a topology variant because one root is truthful for an +// exact mount, plural roots are truthful for a scoped mount, and a legacy +// blank layout establishes neither. Separate Go types keep the wrong root +// claim unrepresentable instead of relying on callers to clear a string field. +type workspaceSyncStatusCommon struct { + WorkspaceID string `json:"workspaceId"` + Mode string `json:"mode"` + Status string `json:"status,omitempty"` + LastReconcileAt string `json:"lastReconcileAt,omitempty"` + LastSuccessfulReconcileAt string `json:"lastSuccessfulReconcileAt,omitempty"` + LastEventAt string `json:"lastEventAt,omitempty"` + IntervalMs int64 `json:"intervalMs"` + Providers []syncStateProvider `json:"providers,omitempty"` + PendingWriteback int `json:"pendingWriteback"` + PendingConflicts int `json:"pendingConflicts"` + DeniedPaths int `json:"deniedPaths"` + FailedWritebacks uint64 `json:"failedWritebacks"` + StallReason string `json:"stallReason,omitempty"` + LastError *statusError `json:"lastError,omitempty"` + IncrementalReadNotReadySince map[string]string `json:"incrementalReadNotReadySince,omitempty"` + Daemon *syncStateDaemon `json:"daemon,omitempty"` + Guards *syncStateGuards `json:"guards,omitempty"` + Bootstrap *syncStateBootstrap `json:"bootstrap,omitempty"` +} + +type exactWorkspaceSyncStatus struct { + workspaceSyncStatusCommon + LocalLayout string `json:"localLayout"` + RemoteRoot string `json:"remoteRoot"` +} + +type scopedWorkspaceSyncStatus struct { + workspaceSyncStatusCommon + LocalLayout string `json:"localLayout"` + RemoteRoots []string `json:"remoteRoots"` +} + +type unknownWorkspaceSyncStatus struct { + workspaceSyncStatusCommon + LocalLayout string `json:"localLayout"` +} + // syncStateBootstrap is the CLI-surface mirror of mountsync's public // bootstrap status block. type syncStateBootstrap struct { @@ -3666,6 +3708,10 @@ func runIntegrationDisconnect(args []string, stdin io.Reader, stdout io.Writer) return nil } } + disconnectPlan, err := prepareProviderDisconnect(record, provider) + if err != nil { + return err + } cloudCreds, err := ensureCloudCredentials(strings.TrimSpace(*cloudAPIURL), "", 5*time.Minute, false, io.Discard) if err != nil { return err @@ -3677,7 +3723,7 @@ func runIntegrationDisconnect(args []string, stdin io.Reader, stdout io.Writer) if _, _, err := client.do(context.Background(), http.MethodDelete, fmt.Sprintf("/api/v1/workspaces/%s/integrations/%s/status", url.PathEscape(record.ID), url.PathEscape(provider)), nil); err != nil { return err } - if err := markProviderDisconnected(record.LocalDir, provider, record.LocalLayout); err != nil { + if err := markProviderDisconnectedWithPlan(record, provider, disconnectPlan); err != nil { return err } fmt.Fprintf(stdout, "%s disconnected from workspace %s\n", provider, record.Name) @@ -4254,6 +4300,241 @@ func absolutePathOrClean(value string) string { return absolute } +// workspaceMountScopes reconstructs the persisted local mount layout. The +// catalog records the common local root, while scoped mounts keep their +// private runtime state beneath the corresponding remote-path subtree. +func workspaceMountScopes(record workspaceRecord) []mountscope.Scope { + localRoot := strings.TrimSpace(record.LocalDir) + if localRoot == "" { + return nil + } + if strings.TrimSpace(record.LocalLayout) != mountscope.LayoutScoped { + // The runtime state is authoritative for an existing exact mount. + // Catalog topology can lag a remount, so it is only a fallback when + // no live root can be recovered. + remoteRoot, _ := readMountRemoteRootIfPresent(localRoot) + if remoteRoot == "" { + for _, candidate := range record.RemotePaths { + if strings.TrimSpace(candidate) != "" { + remoteRoot = mountscope.NormalizePath(candidate) + break + } + } + } + if remoteRoot == "" { + remoteRoot = "/" + } + return []mountscope.Scope{{RemotePath: remoteRoot, LocalDir: localRoot}} + } + paths := mountscope.NormalizePaths(record.RemotePaths, "/") + scopes := make([]mountscope.Scope, 0, len(paths)) + for _, remotePath := range paths { + scopes = append(scopes, mountscope.Scope{ + RemotePath: remotePath, + LocalDir: mountscope.LocalDir(localRoot, remotePath), + }) + } + return scopes +} + +func workspaceRemoteRootForLocalDir(record workspaceRecord, localDir string) (string, error) { + if strings.TrimSpace(record.LocalLayout) == mountscope.LayoutScoped { + target := filepath.Clean(localDir) + for _, scope := range workspaceMountScopes(record) { + if filepath.Clean(scope.LocalDir) == target { + return scope.RemotePath, nil + } + } + return "", fmt.Errorf("scoped mount root is unknown for local directory %s", localDir) + } + if root, ok := readMountRemoteRootIfPresent(localDir); ok { + return root, nil + } + for _, remotePath := range record.RemotePaths { + if strings.TrimSpace(remotePath) != "" { + return mountscope.NormalizePath(remotePath), nil + } + } + return "", fmt.Errorf( + "mount root is unknown for legacy exact mirror %s; restore its .relay/state.json or remount before operating on pending writes", + localDir, + ) +} + +// workspaceStateDirs includes the catalog root for compatibility with +// pre-scoped state, then every persisted scoped runtime root. Consumers use +// this sweep so state written by any sibling mount remains discoverable. +func workspaceStateDirs(record workspaceRecord) []string { + localRoot := strings.TrimSpace(record.LocalDir) + if localRoot == "" { + return nil + } + dirs := []string{localRoot} + seen := map[string]struct{}{filepath.Clean(localRoot): {}} + for _, scope := range workspaceMountScopes(record) { + cleaned := filepath.Clean(scope.LocalDir) + if _, ok := seen[cleaned]; ok { + continue + } + seen[cleaned] = struct{}{} + dirs = append(dirs, scope.LocalDir) + } + return dirs +} + +func workspaceRuntimeStateDirs(record workspaceRecord) []string { + scopes := workspaceMountScopes(record) + dirs := make([]string, 0, len(scopes)) + seen := map[string]struct{}{} + for _, scope := range scopes { + cleaned := filepath.Clean(scope.LocalDir) + if _, ok := seen[cleaned]; ok { + continue + } + seen[cleaned] = struct{}{} + dirs = append(dirs, scope.LocalDir) + } + return dirs +} + +func workspaceStateDirForDeadLetterPaths(record workspaceRecord, rawPaths string) string { + catalogRoot := strings.TrimSpace(record.LocalDir) + paths := deadLetterRetryPaths(rawPaths) + if len(paths) == 0 { + return catalogRoot + } + resolvedDir := "" + for _, path := range paths { + scope, ok := workspaceMountScopeForRemotePath(record, path) + if !ok { + return catalogRoot + } + if resolvedDir == "" { + resolvedDir = scope.LocalDir + continue + } + if filepath.Clean(resolvedDir) != filepath.Clean(scope.LocalDir) { + // One record spanning multiple scoped roots belongs at their + // shared catalog root so every provider's operator preflight can + // discover it. + return catalogRoot + } + } + return resolvedDir +} + +func workspaceMountScopeForRemotePath(record workspaceRecord, remotePath string) (mountscope.Scope, bool) { + path := mountscope.NormalizePath(remotePath) + var best mountscope.Scope + for _, scope := range workspaceMountScopes(record) { + if !mountscope.IsWithin(scope.RemotePath, path) || len(scope.RemotePath) <= len(best.RemotePath) { + continue + } + best = scope + } + return best, strings.TrimSpace(best.RemotePath) != "" +} + +func workspaceRetryTarget(record workspaceRecord, recordLocalDir, remotePath string) (localDir, remoteRoot string, err error) { + if strings.TrimSpace(record.LocalLayout) == mountscope.LayoutScoped { + // The persisted allowlist, not the dead-letter record's storage + // directory, owns retry routing. Compatibility and bulk records can + // live at either the catalog root or one child root. + scope, ok := workspaceMountScopeForRemotePath(record, remotePath) + if !ok { + return "", "", fmt.Errorf( + "dead-letter path %s is outside the persisted scoped allowlist; cannot choose a retry root", + remotePath, + ) + } + return scope.LocalDir, scope.RemotePath, nil + } + remoteRoot, err = workspaceRemoteRootForLocalDir(record, recordLocalDir) + if err != nil { + return "", "", err + } + return recordLocalDir, remoteRoot, nil +} + +type workspaceDeadLetterRetryGroup struct { + LocalDir string + RemoteRoot string + Paths []string +} + +func workspaceDeadLetterRetryGroups(record workspaceRecord, recordLocalDir, rawPaths string) ([]workspaceDeadLetterRetryGroup, error) { + paths := deadLetterRetryPaths(rawPaths) + if len(paths) == 0 { + return nil, errors.New("dead-letter record has no retryable path") + } + groupIndexes := map[string]int{} + groups := make([]workspaceDeadLetterRetryGroup, 0, len(paths)) + for _, remotePath := range paths { + localDir, remoteRoot, err := workspaceRetryTarget(record, recordLocalDir, remotePath) + if err != nil { + return nil, err + } + // Resolve every relative path before any Syncer is invoked so a bad + // cross-scope record cannot partially retry one group and fail later. + if _, err := retryRelativePath(localDir, remoteRoot, remotePath); err != nil { + return nil, err + } + key := filepath.Clean(localDir) + "\x00" + mountscope.NormalizePath(remoteRoot) + if index, ok := groupIndexes[key]; ok { + groups[index].Paths = append(groups[index].Paths, remotePath) + continue + } + groupIndexes[key] = len(groups) + groups = append(groups, workspaceDeadLetterRetryGroup{ + LocalDir: localDir, + RemoteRoot: remoteRoot, + Paths: []string{remotePath}, + }) + } + return groups, nil +} + +// workspaceMountStateFile resolves the same private cursor identity used by +// the Syncer. New mounts keep state beneath MountStateDir; the in-mirror file +// remains only as a compatibility fallback for pre-private-state mounts. +func workspaceMountStateFile(workspaceID string, record workspaceRecord, scope mountscope.Scope) (string, error) { + resolved, err := mountsync.ResolveMountStatePath(mountsync.MountStatePathOptions{ + WorkspaceID: strings.TrimSpace(workspaceID), + RemoteRoot: scope.RemotePath, + LocalRoot: scope.LocalDir, + StateFile: record.MountStateFile, + StateDir: record.MountStateDir, + MountKind: record.MountKind, + }) + if err != nil { + return "", err + } + candidates := []string{ + resolved.StateFile, + filepath.Join(scope.LocalDir, mountsync.LegacyMountStateFileName), + filepath.Join(scope.LocalDir, mountsync.DefaultMountStateDirName, "state.json"), + } + seen := map[string]struct{}{} + for _, candidate := range candidates { + candidate = filepath.Clean(candidate) + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + info, statErr := os.Stat(candidate) + if statErr == nil { + if !info.Mode().IsRegular() { + return "", fmt.Errorf("mount state path %s is not a regular file", candidate) + } + return candidate, nil + } + if !errors.Is(statErr, os.ErrNotExist) { + return "", fmt.Errorf("inspect mount state %s: %w", candidate, statErr) + } + } + return resolved.StateFile, nil +} + func sameFilesystemPath(left, right string) (bool, error) { left = absolutePathOrClean(left) right = absolutePathOrClean(right) @@ -4861,12 +5142,16 @@ func resolveWritebackPushPath(localPath, workspaceValue string) (writebackPushRe if rel == "." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." { return writebackPushResolvedPath{}, fmt.Errorf("%s is outside mount root %s", abs, root) } - remotePath := joinRemotePath(readMountRemoteRoot(mountRoot), filepath.ToSlash(rel)) + remoteRoot, ok := readMountRemoteRootIfPresent(mountRoot) + if !ok { + return writebackPushResolvedPath{}, fmt.Errorf("%s missing remoteRoot", filepath.Join(mountRoot, ".relay", "state.json")) + } + remotePath := joinRemotePath(remoteRoot, filepath.ToSlash(rel)) return writebackPushResolvedPath{ LocalPath: abs, MountRoot: mountRoot, WorkspaceID: workspaceID, - RemoteRoot: readMountRemoteRoot(mountRoot), + RemoteRoot: remoteRoot, RemotePath: remotePath, }, nil } @@ -5127,27 +5412,32 @@ func runWritebackSkipStuck(args []string, stdout io.Writer) error { if timeout <= 0 { timeout = defaultMountTimeout } - client := mountsync.NewHTTPClient(server, tokenValue, &http.Client{ - Transport: newWritebackFailureTransport(record.LocalDir, log.Default(), mountsync.NewSyncTransport()), - }) - remoteRoot := readMountRemoteRoot(record.LocalDir) - websocketDisabled := false - syncer, err := mountsync.NewSyncer(client, mountsync.SyncerOptions{ - WorkspaceID: workspaceID, - RemoteRoot: remoteRoot, - LocalRoot: record.LocalDir, - WebSocket: &websocketDisabled, - RootCtx: context.Background(), - Logger: log.Default(), + scopes := workspaceMountScopes(record) + skipped, backlog, syncErr := skipStuckAcrossScopes(scopes, *maxSkips, func(scope mountscope.Scope, limit int) (int, bool, error) { + client := mountsync.NewHTTPClient(server, tokenValue, &http.Client{ + Transport: newWritebackFailureTransport(scope.LocalDir, log.Default(), mountsync.NewSyncTransport()), + }) + websocketDisabled := false + syncer, err := mountsync.NewSyncer(client, mountsync.SyncerOptions{ + WorkspaceID: workspaceID, + RemoteRoot: scope.RemotePath, + LocalRoot: scope.LocalDir, + StateFile: record.MountStateFile, + StateDir: record.MountStateDir, + MountKind: record.MountKind, + ValidateState: true, + WebSocket: &websocketDisabled, + RootCtx: context.Background(), + Logger: log.Default(), + }) + if err != nil { + return 0, false, err + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + scopeSkipped, err := syncer.SkipStuck(ctx, limit) + return scopeSkipped, syncer.BacklogDraining(), err }) - if err != nil { - return err - } - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - skipped, syncErr := syncer.SkipStuck(ctx, *maxSkips) - backlog := syncer.BacklogDraining() if *jsonOutput { result := struct { @@ -5174,6 +5464,35 @@ func runWritebackSkipStuck(args []string, stdout io.Writer) error { return syncErr } +type skipStuckScopeRunner func(scope mountscope.Scope, maxSkips int) (skipped int, backlog bool, err error) + +func skipStuckAcrossScopes(scopes []mountscope.Scope, maxSkips int, run skipStuckScopeRunner) (int, bool, error) { + totalSkipped := 0 + backlog := false + var runErrors []error + for index, scope := range scopes { + limit := 0 + if maxSkips > 0 { + limit = maxSkips - totalSkipped + if limit <= 0 { + backlog = true + break + } + } + skipped, scopeBacklog, err := run(scope, limit) + totalSkipped += skipped + backlog = backlog || scopeBacklog + if err != nil { + runErrors = append(runErrors, fmt.Errorf("%s: %w", scope.RemotePath, err)) + } + if maxSkips > 0 && totalSkipped >= maxSkips && index < len(scopes)-1 { + backlog = true + break + } + } + return totalSkipped, backlog, errors.Join(runErrors...) +} + func runWritebackStatus(args []string, stdout io.Writer) error { fs := flag.NewFlagSet("writeback status", flag.ContinueOnError) fs.SetOutput(io.Discard) @@ -5191,7 +5510,7 @@ func runWritebackStatus(args []string, stdout io.Writer) error { if err != nil { return err } - report, err := buildWritebackStatusReport(workspaceID, record.LocalDir) + report, err := buildWritebackStatusReport(workspaceID, record) if err != nil { return err } @@ -5241,12 +5560,8 @@ func runWritebackRetry(args []string, stdout io.Writer) error { if strings.TrimSpace(record.LocalDir) == "" { return fmt.Errorf("unknown dead-letter op %q: workspace %s has no local mirror", op, workspaceID) } - recordPath := filepath.Join(deadLetterDirFor(record.LocalDir), op+".json") - payload, err := os.ReadFile(recordPath) + recordPath, recordLocalDir, payload, err := findWorkspaceDeadLetterRecord(record, op) if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("unknown dead-letter op %q", op) - } return err } var dl deadLetterRecord @@ -5260,13 +5575,23 @@ func runWritebackRetry(args []string, stdout io.Writer) error { return fmt.Errorf("dead-letter record %s contains opId %q, expected %q", recordPath, dl.OpID, op) } - if err := retryDeadLetterWriteback(workspaceID, record, dl); err != nil { - return fmt.Errorf("retry op %s: %w", op, err) + retryGroups, err := workspaceDeadLetterRetryGroups(record, recordLocalDir, dl.Path) + if err != nil { + return err + } + for _, group := range retryGroups { + retryRecord := record + retryRecord.LocalDir = group.LocalDir + retryDL := dl + retryDL.Path = strings.Join(group.Paths, ",") + if err := retryDeadLetterWriteback(workspaceID, retryRecord, group.RemoteRoot, retryDL); err != nil { + return fmt.Errorf("retry op %s for mount root %s: %w", op, group.RemoteRoot, err) + } } if err := os.Remove(recordPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("retry queued but failed to remove %s: %w", recordPath, err) } - sidecarPath := deadLetterErrorPathFor(record.LocalDir, op) + sidecarPath := deadLetterErrorPathFor(recordLocalDir, op) if err := os.Remove(sidecarPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("retry queued but failed to remove %s: %w", sidecarPath, err) } @@ -5274,6 +5599,35 @@ func runWritebackRetry(args []string, stdout io.Writer) error { return nil } +func findWorkspaceDeadLetterRecord(record workspaceRecord, opID string) (recordPath, localDir string, payload []byte, err error) { + // Active child roots are authoritative. The catalog root is searched last + // only for compatibility with records written before scoped topology was + // persisted, so a stale compatibility duplicate cannot shadow live state. + candidateDirs := append([]string(nil), workspaceRuntimeStateDirs(record)...) + catalogRoot := strings.TrimSpace(record.LocalDir) + catalogSeen := false + for _, candidateDir := range candidateDirs { + if filepath.Clean(candidateDir) == filepath.Clean(catalogRoot) { + catalogSeen = true + break + } + } + if catalogRoot != "" && !catalogSeen { + candidateDirs = append(candidateDirs, catalogRoot) + } + for _, candidateDir := range candidateDirs { + candidate := filepath.Join(deadLetterDirFor(candidateDir), opID+".json") + payload, err = os.ReadFile(candidate) + if err == nil { + return candidate, candidateDir, payload, nil + } + if !os.IsNotExist(err) { + return "", "", nil, err + } + } + return "", "", nil, fmt.Errorf("unknown dead-letter op %q", opID) +} + func runOps(args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 { return errors.New("ops subcommand is required: list or replay") @@ -5329,7 +5683,7 @@ func runOpsList(args []string, stdout io.Writer) error { } } - records, err := readDeadLetterRecords(record.LocalDir) + records, err := readWorkspaceDeadLetterRecords(record) if err != nil { return err } @@ -5387,6 +5741,27 @@ func readDeadLetterRecords(localDir string) ([]deadLetterRecord, error) { return records, nil } +func readWorkspaceDeadLetterRecords(record workspaceRecord) ([]deadLetterRecord, error) { + recordsByID := map[string]deadLetterRecord{} + for _, localDir := range workspaceStateDirs(record) { + records, err := readDeadLetterRecords(localDir) + if err != nil { + return nil, err + } + for _, record := range records { + recordsByID[record.OpID] = record + } + } + records := make([]deadLetterRecord, 0, len(recordsByID)) + for _, record := range recordsByID { + records = append(records, record) + } + sort.Slice(records, func(i, j int) bool { + return records[i].OpID < records[j].OpID + }) + return records, nil +} + func resolveWorkspaceLikeStatus(value string) (string, workspaceRecord, error) { // CodeRabbit flagged on PR #84: `writeback status` should work // offline / with expired creds — it only inspects local mirror @@ -5443,31 +5818,37 @@ func resolveWorkspaceLikeStatus(value string) (string, workspaceRecord, error) { return workspaceID, record, nil } -func buildWritebackStatusReport(workspaceID, localDir string) (writebackStatusReport, error) { +func buildWritebackStatusReport(workspaceID string, workspace workspaceRecord) (writebackStatusReport, error) { report := writebackStatusReport{ WorkspaceID: workspaceID, DeadLettered: []writebackStatusDeadLetter{}, LastErrorByProvider: map[string]string{}, } - if strings.TrimSpace(localDir) == "" { + if strings.TrimSpace(workspace.LocalDir) == "" { return report, nil } - state, err := readWritebackState(localDir) - if err != nil { - return writebackStatusReport{}, err - } - report.Pending = state.PendingWriteback - report.Failed = state.FailedWritebacks - for _, provider := range state.Providers { - name := strings.TrimSpace(provider.Provider) - lastError := strings.TrimSpace(provider.LastError) - if name != "" && lastError != "" { - report.LastErrorByProvider[name] = lastError + for _, scope := range workspaceMountScopes(workspace) { + state, err := readWritebackState(scope.LocalDir) + if err != nil { + return writebackStatusReport{}, err + } + stateFile, err := workspaceMountStateFile(workspaceID, workspace, scope) + if err != nil { + return writebackStatusReport{}, err + } + report.Pending += countPendingTrackedFilesAt(stateFile) + report.Failed += state.FailedWritebacks + for _, provider := range state.Providers { + name := strings.TrimSpace(provider.Provider) + lastError := strings.TrimSpace(provider.LastError) + if name != "" && lastError != "" { + report.LastErrorByProvider[name] = lastError + } } } - records, err := readDeadLetterRecords(localDir) + records, err := readWorkspaceDeadLetterRecords(workspace) if err != nil { return writebackStatusReport{}, err } @@ -5544,7 +5925,7 @@ func printWritebackStatus(stdout io.Writer, record workspaceRecord, report write } } -func retryDeadLetterWriteback(workspaceID string, record workspaceRecord, dl deadLetterRecord) error { +func retryDeadLetterWriteback(workspaceID string, record workspaceRecord, remoteRoot string, dl deadLetterRecord) error { if strings.TrimSpace(record.LocalDir) == "" { return errors.New("workspace has no local mirror") } @@ -5577,21 +5958,18 @@ func retryDeadLetterWriteback(workspaceID string, record workspaceRecord, dl dea client := mountsync.NewHTTPClient(server, tokenValue, &http.Client{ Transport: newWritebackFailureTransport(record.LocalDir, log.Default(), mountsync.NewSyncTransport()), }) - // Read the live mount's remoteRoot from .relay/state.json instead - // of hardcoding "/". CodeRabbit flagged on PR #84: a mount created - // with `--remote-path /github` has dead-letter paths under /github, - // and retrying with RemoteRoot:"/" would look up `/github/...` - // instead of `/...`, so replay would fail even though the - // mirrored file exists. - remoteRoot := readMountRemoteRoot(record.LocalDir) websocketDisabled := false syncer, err := mountsync.NewSyncer(client, mountsync.SyncerOptions{ - WorkspaceID: workspaceID, - RemoteRoot: remoteRoot, - LocalRoot: record.LocalDir, - WebSocket: &websocketDisabled, - RootCtx: context.Background(), - Logger: log.Default(), + WorkspaceID: workspaceID, + RemoteRoot: remoteRoot, + LocalRoot: record.LocalDir, + StateFile: record.MountStateFile, + StateDir: record.MountStateDir, + MountKind: record.MountKind, + ValidateState: true, + WebSocket: &websocketDisabled, + RootCtx: context.Background(), + Logger: log.Default(), }) if err != nil { return err @@ -5612,28 +5990,24 @@ func retryDeadLetterWriteback(workspaceID string, record workspaceRecord, dl dea return nil } -// readMountRemoteRoot reads the live mount's remoteRoot from -// /.relay/state.json. Defaults to "/" when missing or -// unparseable so retry on a root mount works without state.json -// being present. -func readMountRemoteRoot(localDir string) string { +func readMountRemoteRootIfPresent(localDir string) (string, bool) { if strings.TrimSpace(localDir) == "" { - return "/" + return "", false } data, err := os.ReadFile(filepath.Join(localDir, ".relay", "state.json")) if err != nil { - return "/" + return "", false } var s struct { RemoteRoot string `json:"remoteRoot"` } if json.Unmarshal(data, &s) != nil { - return "/" + return "", false } if root := strings.TrimSpace(s.RemoteRoot); root != "" { - return root + return root, true } - return "/" + return "", false } func deadLetterRetryPaths(raw string) []string { @@ -5695,19 +6069,100 @@ func firstNonBlank(values ...string) string { } type opsListResponse struct { - Items []struct { - OpID string `json:"opId"` - Path string `json:"path,omitempty"` - Action string `json:"action,omitempty"` - Provider string `json:"provider,omitempty"` - Status string `json:"status"` - AttemptCount int `json:"attemptCount"` - LastError *string `json:"lastError,omitempty"` - CreatedAt string `json:"createdAt,omitempty"` - UpdatedAt string `json:"updatedAt,omitempty"` - CorrelationID string `json:"correlationId,omitempty"` - } `json:"items"` - NextCursor *string `json:"nextCursor,omitempty"` + Items []opsListItem `json:"items"` + NextCursor *string `json:"nextCursor,omitempty"` +} + +type opsListItem struct { + OpID string `json:"opId"` + Path string `json:"path,omitempty"` + Action string `json:"action,omitempty"` + Provider string `json:"provider,omitempty"` + Status string `json:"status"` + AttemptCount int `json:"attemptCount"` + LastError *string `json:"lastError,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + CorrelationID string `json:"correlationId,omitempty"` +} + +// deadLetterRefreshPlan exists only for a complete, validated server set. +// Callers cannot accidentally prune from a partial response because no plan +// is returned for a missing array, unsafe id, or duplicate id. +type deadLetterRefreshPlan struct { + items []opsListItem + targetDirs map[string]string +} + +func validateDeadLetterRefreshPlan(record workspaceRecord, feed opsListResponse) (deadLetterRefreshPlan, error) { + if feed.Items == nil { + return deadLetterRefreshPlan{}, errors.New("dead-letter response is missing required items array") + } + plan := deadLetterRefreshPlan{ + items: feed.Items, + targetDirs: make(map[string]string, len(feed.Items)), + } + for _, item := range feed.Items { + opID := safeWritebackOpID(item.OpID) + if opID == "" { + return deadLetterRefreshPlan{}, fmt.Errorf( + "dead-letter response contains unsafe or missing opId %q", + strings.TrimSpace(item.OpID), + ) + } + if _, exists := plan.targetDirs[opID]; exists { + return deadLetterRefreshPlan{}, fmt.Errorf("dead-letter response contains duplicate opId %q", opID) + } + targetDir := workspaceStateDirForDeadLetterPaths(record, item.Path) + if targetDir == "" { + targetDir = record.LocalDir + } + plan.targetDirs[opID] = targetDir + } + return plan, nil +} + +func fetchAllDeadLetterOps(ctx context.Context, client *apiClient, workspaceID string) (opsListResponse, error) { + items := make([]opsListItem, 0) + cursor := "" + seenCursors := map[string]struct{}{} + for { + query := url.Values{ + "status": []string{"dead_lettered"}, + "limit": []string{"200"}, + } + if cursor != "" { + query.Set("cursor", cursor) + } + var page opsListResponse + if err := client.getJSON( + ctx, + fmt.Sprintf("/v1/workspaces/%s/ops?%s", url.PathEscape(workspaceID), query.Encode()), + &page, + ); err != nil { + return opsListResponse{}, err + } + if page.Items == nil { + return opsListResponse{}, errors.New("dead-letter response is missing required items array") + } + items = append(items, page.Items...) + + nextCursor := "" + if page.NextCursor != nil { + nextCursor = strings.TrimSpace(*page.NextCursor) + } + if nextCursor == "" { + return opsListResponse{Items: items}, nil + } + if nextCursor == cursor { + return opsListResponse{}, fmt.Errorf("dead-letter response repeated cursor %q", nextCursor) + } + if _, exists := seenCursors[nextCursor]; exists { + return opsListResponse{}, fmt.Errorf("dead-letter response cursor loop detected at %q", nextCursor) + } + seenCursors[nextCursor] = struct{}{} + cursor = nextCursor + } } // refreshDeadLetterMirror reconciles the local .relay/dead-letter/ directory @@ -5728,31 +6183,23 @@ func refreshDeadLetterMirror(record workspaceRecord, serverOverride, tokenOverri return err } - var feed opsListResponse - if err := client.getJSON( - context.Background(), - fmt.Sprintf("/v1/workspaces/%s/ops?status=dead_lettered&limit=200", url.PathEscape(record.ID)), - &feed, - ); err != nil { + feed, err := fetchAllDeadLetterOps(context.Background(), client, record.ID) + if err != nil { return err } - - dir := deadLetterDirFor(record.LocalDir) - if err := os.MkdirAll(dir, 0o755); err != nil { + plan, err := validateDeadLetterRefreshPlan(record, feed) + if err != nil { return err } - keep := make(map[string]struct{}, len(feed.Items)) baseURL := strings.TrimRight(client.baseURL, "/") - for _, item := range feed.Items { + for _, item := range plan.items { opID := safeWritebackOpID(item.OpID) - if opID == "" { - if trimmed := strings.TrimSpace(item.OpID); trimmed != "" { - fmt.Fprintf(os.Stderr, "warning: skipping dead-letter op with unsafe id %q\n", trimmed) - } - continue + targetDir := plan.targetDirs[opID] + dir := deadLetterDirFor(targetDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err } - keep[opID] = struct{}{} message := "" if item.LastError != nil { message = strings.TrimSpace(*item.LastError) @@ -5783,27 +6230,31 @@ func refreshDeadLetterMirror(record workspaceRecord, serverOverride, tokenOverri } // Prune local payload records the server no longer reports as - // dead-lettered. Diagnostic sidecars (.error.json) are bound to - // their payload's lifecycle: they are skipped here and removed together - // with the payload, never evaluated as standalone payload records. - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || !strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".error.json") { - continue + // dead-lettered, plus stale duplicates left at a different scope root. + // Diagnostic sidecars (.error.json) are bound to their payload's + // lifecycle and removed together with the payload. + for _, localDir := range workspaceStateDirs(record) { + dir := deadLetterDirFor(localDir) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + continue + } + return err } - opID := strings.TrimSuffix(name, ".json") - if _, ok := keep[opID]; ok { - continue + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".error.json") { + continue + } + opID := strings.TrimSuffix(name, ".json") + targetDir, retained := plan.targetDirs[opID] + if retained && filepath.Clean(targetDir) == filepath.Clean(localDir) { + continue + } + _ = os.Remove(filepath.Join(dir, name)) + _ = os.Remove(deadLetterErrorPathFor(localDir, opID)) } - _ = os.Remove(filepath.Join(dir, name)) - _ = os.Remove(deadLetterErrorPathFor(record.LocalDir, opID)) } return nil } @@ -5865,12 +6316,12 @@ func runOpsReplay(args []string, stdin io.Reader, stdout io.Writer) error { } // Per contract §8.4, on successful replay the local mirror record is // removed so the user's view stays in sync with the queue. - if record.LocalDir != "" { - path := filepath.Join(deadLetterDirFor(record.LocalDir), opID+".json") + for _, localDir := range workspaceStateDirs(record) { + path := filepath.Join(deadLetterDirFor(localDir), opID+".json") if err := os.Remove(path); err != nil && !os.IsNotExist(err) { fmt.Fprintf(stdout, "warning: replay queued but failed to remove %s: %v\n", path, err) } - sidecar := deadLetterErrorPathFor(record.LocalDir, opID) + sidecar := deadLetterErrorPathFor(localDir, opID) if err := os.Remove(sidecar); err != nil && !os.IsNotExist(err) { fmt.Fprintf(stdout, "warning: replay queued but failed to remove %s: %v\n", sidecar, err) } @@ -6244,29 +6695,89 @@ func buildWorkspaceHealthReport(workspaceID string, record workspaceRecord) work if report.LocalDir == "" { return report } - state := readWritebackStateBestEffort(report.LocalDir) - report.Status = strings.TrimSpace(state.Status) - report.LastSuccessfulReconcileAt = strings.TrimSpace(state.LastSuccessfulReconcileAt) - report.LastReconcileAt = strings.TrimSpace(state.LastReconcileAt) - if state.LastError != nil { - report.LastError = strings.TrimSpace(firstNonBlank(state.LastError.Message, state.LastError.Code)) - } - // Use the public sync state's not-ready set as the stuck-event baseline so - // the count is non-zero even when the private cursor files are absent or - // the first readable one lacks the field; then take the max with the - // private cursor health (which also carries the backlog-draining flag). - report.StuckEventCount = len(state.IncrementalReadNotReadySince) - cursorStuckCount, backlogDraining := readLocalMountCursorHealth(report.LocalDir) - if cursorStuckCount > report.StuckEventCount { - report.StuckEventCount = cursorStuckCount - } - report.IncrementalBacklogDraining = backlogDraining - report.OutboxPending = countJSONFiles(filepath.Join(report.LocalDir, ".relay", "outbox", "pending")) - report.OutboxFailed = countJSONFiles(filepath.Join(report.LocalDir, ".relay", "outbox", "failed")) - report.OutboxAcked = countJSONFiles(filepath.Join(report.LocalDir, ".relay", "outbox", "acked")) + statuses := map[string]struct{}{} + errorsSeen := map[string]struct{}{} + lastSuccessfulReconcileUnknown := false + lastReconcileUnknown := false + for _, scope := range workspaceMountScopes(record) { + localDir := scope.LocalDir + state := readWritebackStateBestEffort(localDir) + if status := strings.TrimSpace(state.Status); status != "" { + statuses[status] = struct{}{} + } + report.LastSuccessfulReconcileAt, lastSuccessfulReconcileUnknown = latestRFC3339Timestamp( + report.LastSuccessfulReconcileAt, + state.LastSuccessfulReconcileAt, + lastSuccessfulReconcileUnknown, + ) + report.LastReconcileAt, lastReconcileUnknown = latestRFC3339Timestamp( + report.LastReconcileAt, + state.LastReconcileAt, + lastReconcileUnknown, + ) + if state.LastError != nil { + if message := strings.TrimSpace(firstNonBlank(state.LastError.Message, state.LastError.Code)); message != "" { + errorsSeen[message] = struct{}{} + } + } + // Each scoped loop owns an independent cursor. Within one root, retain + // the existing max(public state, private cursor) rule. + scopeStuckCount := len(state.IncrementalReadNotReadySince) + cursorStuckCount, backlogDraining := readWorkspaceMountCursorHealth(workspaceID, record, scope) + if cursorStuckCount > scopeStuckCount { + scopeStuckCount = cursorStuckCount + } + report.StuckEventCount += scopeStuckCount + report.IncrementalBacklogDraining = report.IncrementalBacklogDraining || backlogDraining + } + // Outbox state can remain at the catalog root for compatibility even when + // active scoped loops own child roots. Sweep the complete deduplicated + // state-dir set so health never reports a clean queue by omission. + for _, localDir := range workspaceStateDirs(record) { + report.OutboxPending += countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "pending")).Count + report.OutboxFailed += countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "failed")).Count + report.OutboxAcked += countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "acked")).Count + } + report.Status = joinSortedKeys(statuses) + report.LastError = joinSortedKeys(errorsSeen) return report } +func latestRFC3339Timestamp(current, candidate string, unknown bool) (string, bool) { + if unknown { + return "", true + } + current = strings.TrimSpace(current) + candidate = strings.TrimSpace(candidate) + if candidate == "" { + return current, false + } + candidateTime, candidateErr := time.Parse(time.RFC3339Nano, candidate) + if candidateErr != nil { + // One malformed child makes the aggregate latest time unknown. Picking + // a parseable sibling would present a confident answer from partial + // persisted state. + return "", true + } + currentTime, currentErr := time.Parse(time.RFC3339Nano, current) + if current != "" && currentErr != nil { + return "", true + } + if current == "" || candidateTime.After(currentTime) { + return candidate, false + } + return current, false +} + +func joinSortedKeys(values map[string]struct{}) string { + keys := make([]string, 0, len(values)) + for value := range values { + keys = append(keys, value) + } + sort.Strings(keys) + return strings.Join(keys, "; ") +} + func readWritebackStateBestEffort(localDir string) syncStateFile { state, err := readWritebackState(localDir) if err == nil { @@ -6275,34 +6786,37 @@ func readWritebackStateBestEffort(localDir string) syncStateFile { return syncStateFile{} } -func readLocalMountCursorHealth(localDir string) (stuckCount int, backlogDraining bool) { - for _, path := range []string{ - filepath.Join(localDir, ".relayfile-mount-state.json"), - filepath.Join(localDir, mountsync.DefaultMountStateDirName, "state.json"), - } { - payload, err := os.ReadFile(path) - if err != nil { - continue - } - var state struct { - IncrementalReadNotReadySince map[string]string `json:"incrementalReadNotReadySince"` - IncrementalBacklogDraining bool `json:"incrementalBacklogDraining"` - } - if json.Unmarshal(payload, &state) != nil { - continue - } - if count := len(state.IncrementalReadNotReadySince); count > stuckCount { - stuckCount = count - } - backlogDraining = backlogDraining || state.IncrementalBacklogDraining +func readWorkspaceMountCursorHealth(workspaceID string, record workspaceRecord, scope mountscope.Scope) (stuckCount int, backlogDraining bool) { + stateFile, err := workspaceMountStateFile(workspaceID, record, scope) + if err != nil { + return 0, false } - return stuckCount, backlogDraining + payload, err := os.ReadFile(stateFile) + if err != nil { + return 0, false + } + var state struct { + IncrementalReadNotReadySince map[string]string `json:"incrementalReadNotReadySince"` + IncrementalBacklogDraining bool `json:"incrementalBacklogDraining"` + } + if json.Unmarshal(payload, &state) != nil { + return 0, false + } + return len(state.IncrementalReadNotReadySince), state.IncrementalBacklogDraining +} + +type observedFileCount struct { + Count int + Err error } -func countJSONFiles(dir string) int { +func countJSONFiles(dir string) observedFileCount { entries, err := os.ReadDir(dir) if err != nil { - return 0 + if errors.Is(err, os.ErrNotExist) { + return observedFileCount{} + } + return observedFileCount{Err: err} } count := 0 for _, entry := range entries { @@ -6310,7 +6824,7 @@ func countJSONFiles(dir string) int { count++ } } - return count + return observedFileCount{Count: count} } func printWorkspaceHealthReport(stdout io.Writer, report workspaceHealthReport) { @@ -6893,10 +7407,8 @@ func runMount(args []string) error { }) } -// Scoped runtime state is implemented below this CLI boundary, but its -// operator surfaces are not yet complete. Refuse every route that can select -// scoped layout (flag, environment, or recorded catalog inheritance) until -// status/list/retry can see every scoped child state location. +// resolveCLIRequestedLocalLayout gives the explicit flag precedence over the +// environment and persisted topology, then validates the selected layout. func resolveCLIRequestedLocalLayout(flagValue, envValue, recordedValue string, flagProvided bool) (string, error) { resolved := flagValue if !flagProvided { @@ -6910,9 +7422,6 @@ func resolveCLIRequestedLocalLayout(flagValue, envValue, recordedValue string, f if err != nil { return "", fmt.Errorf("resolve local layout: %w", err) } - if resolved == mountscope.LayoutScoped { - return "", fmt.Errorf("--local-layout=%s is temporarily unavailable until scoped operator surfaces are ready; use --local-layout=%s", mountscope.LayoutScoped, mountscope.LayoutExact) - } return resolved, nil } @@ -8489,10 +8998,10 @@ func runStatus(args []string, stdout io.Writer) error { return err } workspaceID := commandClient.workspaceID - persistedStallReason := readPersistedStallReason(record.LocalDir) - snapshot := buildSyncStateSnapshot(status, workspaceID, defaultMountMode, defaultMountInterval, record.LocalDir, readDaemonPID(record.LocalDir), persistedStallReason) + snapshot := buildWorkspaceSyncStateSnapshot(status, workspaceID, record) + persistedStallReason := snapshot.StallReason if *jsonOutput { - return writeJSON(stdout, snapshot) + return writeJSON(stdout, workspaceSyncStatusJSON(snapshot, record)) } workspaceLabel := workspaceID if strings.TrimSpace(record.Name) != "" && record.Name != workspaceID { @@ -8564,6 +9073,159 @@ func readPersistedStallReason(localDir string) string { return strings.TrimSpace(snapshot.StallReason) } +func buildWorkspaceSyncStateSnapshot(status syncStatusResponse, workspaceID string, record workspaceRecord) syncStateFile { + localRoot := strings.TrimSpace(record.LocalDir) + snapshot := buildSyncStateSnapshot(status, workspaceID, defaultMountMode, defaultMountInterval, localRoot, readDaemonPID(localRoot), "") + if strings.TrimSpace(record.LocalLayout) == mountscope.LayoutExact { + if runtimeRoot, ok := readMountRemoteRootIfPresent(localRoot); ok { + snapshot.RemoteRoot = runtimeRoot + } else { + snapshot.RemoteRoot = mountscope.NormalizePaths(record.RemotePaths, "/")[0] + } + } + snapshot.PendingWriteback = 0 + snapshot.PendingConflicts = 0 + snapshot.DeniedPaths = 0 + snapshot.FailedWritebacks = 0 + snapshot.StallReason = "" + snapshot.Guards = nil + snapshot.Bootstrap = nil + + stallReasons := map[string]struct{}{} + for _, scope := range workspaceMountScopes(record) { + localDir := scope.LocalDir + local := buildSyncStateSnapshot(syncStatusResponse{}, workspaceID, defaultMountMode, defaultMountInterval, localDir, 0, readPersistedStallReason(localDir)) + if stateFile, err := workspaceMountStateFile(workspaceID, record, scope); err == nil { + local.PendingWriteback = countPendingTrackedFilesAt(stateFile) + } + snapshot.PendingWriteback += local.PendingWriteback + snapshot.PendingConflicts += local.PendingConflicts + snapshot.DeniedPaths += local.DeniedPaths + snapshot.FailedWritebacks += local.FailedWritebacks + if local.StallReason != "" { + stallReasons[local.StallReason] = struct{}{} + } + snapshot.Guards = mergeSyncStateGuards(snapshot.Guards, local.Guards) + snapshot.Bootstrap = mergeSyncStateBootstrap(snapshot.Bootstrap, local.Bootstrap) + } + reasons := make([]string, 0, len(stallReasons)) + for reason := range stallReasons { + reasons = append(reasons, reason) + } + sort.Strings(reasons) + snapshot.StallReason = strings.Join(reasons, "; ") + return snapshot +} + +func workspaceSyncStatusJSON(snapshot syncStateFile, record workspaceRecord) any { + common := workspaceSyncStatusCommon{ + WorkspaceID: snapshot.WorkspaceID, + Mode: snapshot.Mode, + Status: snapshot.Status, + LastReconcileAt: snapshot.LastReconcileAt, + LastSuccessfulReconcileAt: snapshot.LastSuccessfulReconcileAt, + LastEventAt: snapshot.LastEventAt, + IntervalMs: snapshot.IntervalMs, + Providers: snapshot.Providers, + PendingWriteback: snapshot.PendingWriteback, + PendingConflicts: snapshot.PendingConflicts, + DeniedPaths: snapshot.DeniedPaths, + FailedWritebacks: snapshot.FailedWritebacks, + StallReason: snapshot.StallReason, + LastError: snapshot.LastError, + IncrementalReadNotReadySince: snapshot.IncrementalReadNotReadySince, + Daemon: snapshot.Daemon, + Guards: snapshot.Guards, + Bootstrap: snapshot.Bootstrap, + } + switch strings.TrimSpace(record.LocalLayout) { + case mountscope.LayoutExact: + root := strings.TrimSpace(snapshot.RemoteRoot) + if root == "" { + root = mountscope.NormalizePaths(record.RemotePaths, "/")[0] + } + return exactWorkspaceSyncStatus{ + workspaceSyncStatusCommon: common, + LocalLayout: mountscope.LayoutExact, + RemoteRoot: root, + } + case mountscope.LayoutScoped: + return scopedWorkspaceSyncStatus{ + workspaceSyncStatusCommon: common, + LocalLayout: mountscope.LayoutScoped, + RemoteRoots: mountscope.NormalizePaths(record.RemotePaths, "/"), + } + default: + return unknownWorkspaceSyncStatus{ + workspaceSyncStatusCommon: common, + LocalLayout: "unknown", + } + } +} + +func mergeSyncStateBootstrap(current, next *syncStateBootstrap) *syncStateBootstrap { + if next == nil { + return current + } + if current == nil { + copy := *next + return © + } + current.FilesSynced += next.FilesSynced + current.FilesTotal += next.FilesTotal + if current.Phase == "" { + current.Phase = next.Phase + } else if next.Phase != "" && next.Phase != current.Phase { + current.Phase = "multiple" + } + if current.StartedAt == "" || (next.StartedAt != "" && next.StartedAt < current.StartedAt) { + current.StartedAt = next.StartedAt + } + return current +} + +func mergeSyncStateGuards(current, next *syncStateGuards) *syncStateGuards { + if next == nil { + return current + } + if current == nil { + copy := *next + if next.Circuit != nil { + circuit := *next.Circuit + copy.Circuit = &circuit + } + return © + } + current.SkippedOversizeWriteback += next.SkippedOversizeWriteback + current.DeniedRootTarget += next.DeniedRootTarget + current.SnapshotDeleteBlocked += next.SnapshotDeleteBlocked + current.CircuitOpenEvents += next.CircuitOpenEvents + current.TombstonesPending += next.TombstonesPending + current.TombstonesConfirmed += next.TombstonesConfirmed + current.TombstonesAgedOut += next.TombstonesAgedOut + current.PathCollisionQuarantined += next.PathCollisionQuarantined + if current.LastAppliedRevision == "" { + current.LastAppliedRevision = next.LastAppliedRevision + } else if next.LastAppliedRevision != "" && next.LastAppliedRevision != current.LastAppliedRevision { + current.LastAppliedRevision = "multiple" + } + if next.Circuit != nil && current.Circuit == nil { + circuit := *next.Circuit + current.Circuit = &circuit + } else if next.Circuit != nil { + current.Circuit.Open = current.Circuit.Open || next.Circuit.Open + current.Circuit.OpenEvents += next.Circuit.OpenEvents + current.Circuit.Failures += next.Circuit.Failures + if current.Circuit.OpenedAt == "" || (next.Circuit.OpenedAt != "" && next.Circuit.OpenedAt < current.Circuit.OpenedAt) { + current.Circuit.OpenedAt = next.Circuit.OpenedAt + } + if next.Circuit.NextRetry > current.Circuit.NextRetry { + current.Circuit.NextRetry = next.Circuit.NextRetry + } + } + return current +} + func statusAuthLine(localDir string, now time.Time) string { if line := daemonCredentialFreshnessAuthLine(localDir); line != "" { return line @@ -10835,13 +11497,14 @@ func providerReadyForMirror(client *apiClient, workspaceID, provider string, sta } func buildSyncStateSnapshot(status syncStatusResponse, workspaceID, mode string, interval time.Duration, localDir string, pid int, stallReason string) syncStateFile { + remoteRoot, _ := readMountRemoteRootIfPresent(localDir) snapshot := syncStateFile{ WorkspaceID: workspaceID, - RemoteRoot: readMountRemoteRoot(localDir), + RemoteRoot: remoteRoot, Mode: defaultIfBlank(mode, defaultMountMode), IntervalMs: interval.Milliseconds(), - PendingWriteback: countDirtyTrackedFiles(localDir), - PendingConflicts: countFilesInDir(filepath.Join(localDir, ".relay", "conflicts")), + PendingWriteback: countPendingTrackedFiles(localDir), + PendingConflicts: countFilesInDir(filepath.Join(localDir, ".relay", "conflicts")).Count, DeniedPaths: countLines(filepath.Join(localDir, ".relay", "permissions-denied.log")), FailedWritebacks: readPersistedFailedWritebacks(localDir), StallReason: stallReason, @@ -11066,35 +11729,48 @@ func uint64FromJSONValue(value any) uint64 { return 0 } -func countDirtyTrackedFiles(localDir string) int { +func countPendingTrackedFiles(localDir string) int { if localDir == "" { return 0 } + return countPendingTrackedFilesAt(filepath.Join(localDir, mountsync.LegacyMountStateFileName)) +} + +func countPendingTrackedFilesAt(stateFile string) int { + count, _ := pendingTrackedFileCountAt(stateFile) + return count +} + +func pendingTrackedFileCountAt(stateFile string) (int, error) { var state struct { - Files map[string]struct { - Dirty bool `json:"dirty"` - } `json:"files"` + Files map[string]mountsync.TrackedFileState `json:"files"` } - payload, err := os.ReadFile(filepath.Join(localDir, ".relayfile-mount-state.json")) + payload, err := os.ReadFile(stateFile) if err != nil { - return 0 + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + return 0, err } if err := json.Unmarshal(payload, &state); err != nil { - return 0 + return 0, err } count := 0 for _, tracked := range state.Files { - if tracked.Dirty { + if tracked.HasPendingWriteback() { count++ } } - return count + return count, nil } -func countFilesInDir(dir string) int { +func countFilesInDir(dir string) observedFileCount { entries, err := os.ReadDir(dir) if err != nil { - return 0 + if errors.Is(err, os.ErrNotExist) { + return observedFileCount{} + } + return observedFileCount{Err: err} } count := 0 for _, entry := range entries { @@ -11102,14 +11778,18 @@ func countFilesInDir(dir string) int { if entry.Name() == "resolved" { continue } - count += countFilesInDir(filepath.Join(dir, entry.Name())) + child := countFilesInDir(filepath.Join(dir, entry.Name())) + count += child.Count + if child.Err != nil { + return observedFileCount{Count: count, Err: child.Err} + } continue } if !entry.IsDir() { count++ } } - return count + return observedFileCount{Count: count} } func countLines(path string) int { @@ -11780,20 +12460,21 @@ func loadSavedConnectionID(localDir, provider string) string { return strings.TrimSpace(state.ConnectionID) } -func markProviderDisconnected(localDir, provider, localLayout string) error { +func markProviderDisconnected(record workspaceRecord, provider string) error { + plan, err := prepareProviderDisconnect(record, provider) + if err != nil { + return err + } + return markProviderDisconnectedWithPlan(record, provider, plan) +} + +func markProviderDisconnectedWithPlan(record workspaceRecord, provider string, plan providerDisconnectPlan) error { + localDir := strings.TrimSpace(record.LocalDir) if localDir == "" { return nil } - providerPath := filepath.Join(localDir, providerRootDir(provider)) - if strings.TrimSpace(localLayout) == mountscope.LayoutScoped { - // Scoped child roots own runtime state under .relay. Remove only - // mirrored provider content; recursively deleting the child would - // destroy outbox/dead-letter/retry state while a syncer may use it. - if err := removeScopedProviderContent(providerPath); err != nil { - return err - } - } else { - _ = os.RemoveAll(providerPath) + if err := removeProviderMirrorWithPlan(plan); err != nil { + return err } if err := ensureMountRuntimeLayout(localDir); err != nil { return err @@ -11814,37 +12495,486 @@ func markProviderDisconnected(localDir, provider, localLayout string) error { return nil } -func removeScopedProviderContent(root string) error { - entries, err := os.ReadDir(root) - if errors.Is(err, os.ErrNotExist) { +type providerDisconnectPlan struct { + cleanScopeDirs []string + removeSubtrees []string + stateDirs []string + wholeProviderDirs []string + stateFiles []string + stateSources []providerDisconnectStateSource + unknownStateRoots []string + deletions []providerDisconnectDeletion +} + +type providerDisconnectDeletion struct { + path string + kind string + fingerprint string +} + +type providerDisconnectStateSource struct { + stateFile string + localDir string + remoteRoot string + scopedChild bool +} + +func planProviderDisconnect(record workspaceRecord, provider string) (providerDisconnectPlan, error) { + localRoot := strings.TrimSpace(record.LocalDir) + if localRoot == "" { + return providerDisconnectPlan{}, nil + } + providerRoot := mountscope.NormalizePath("/" + providerRootDir(provider)) + var plan providerDisconnectPlan + addScopeState := func(scope mountscope.Scope) error { + stateFiles, err := workspaceMountStateFilesForRecord(record, scope) + if err != nil { + return err + } + if len(stateFiles) == 0 { + plan.unknownStateRoots = append(plan.unknownStateRoots, scope.LocalDir) + return nil + } + for _, stateFile := range stateFiles { + plan.stateFiles = append(plan.stateFiles, stateFile) + plan.stateSources = append(plan.stateSources, providerDisconnectStateSource{ + stateFile: stateFile, + localDir: scope.LocalDir, + remoteRoot: scope.RemotePath, + scopedChild: strings.TrimSpace(record.LocalLayout) == mountscope.LayoutScoped, + }) + } return nil } + if strings.TrimSpace(record.LocalLayout) != mountscope.LayoutScoped { + scope := workspaceMountScopes(record)[0] + if err := addScopeState(scope); err != nil { + return providerDisconnectPlan{}, err + } + plan.removeSubtrees = append(plan.removeSubtrees, filepath.Join(localRoot, filepath.FromSlash(strings.TrimPrefix(providerRoot, "/")))) + plan.stateDirs = append(plan.stateDirs, localRoot) + return plan, nil + } + + // Scoped mounts can retain compatibility dead letters at the catalog + // root, including bulk operations spanning more than one child. Inspect + // that root with provider filtering before any destructive disconnect. + plan.stateDirs = append(plan.stateDirs, localRoot) + matchedScope := false + for _, scope := range workspaceMountScopes(record) { + switch { + case mountscope.IsWithin(providerRoot, scope.RemotePath): + // This scope's .relay directory contains conflicts, dead letters, + // outbox entries, and cursor state. Remove only mirrored content. + matchedScope = true + plan.cleanScopeDirs = append(plan.cleanScopeDirs, scope.LocalDir) + plan.stateDirs = append(plan.stateDirs, scope.LocalDir) + plan.wholeProviderDirs = append(plan.wholeProviderDirs, scope.LocalDir) + if err := addScopeState(scope); err != nil { + return providerDisconnectPlan{}, err + } + case mountscope.IsWithin(scope.RemotePath, providerRoot): + // A broader scope (normally "/") keeps its runtime state outside + // the provider subtree, so the provider subtree is safe to remove. + matchedScope = true + relative := strings.TrimPrefix(providerRoot, scope.RemotePath) + relative = strings.TrimPrefix(relative, "/") + plan.removeSubtrees = append(plan.removeSubtrees, filepath.Join(scope.LocalDir, filepath.FromSlash(relative))) + plan.stateDirs = append(plan.stateDirs, scope.LocalDir) + if err := addScopeState(scope); err != nil { + return providerDisconnectPlan{}, err + } + } + } + if !matchedScope { + legacyStateFile := filepath.Join(localRoot, mountsync.LegacyMountStateFileName) + plan.removeSubtrees = append(plan.removeSubtrees, filepath.Join(localRoot, filepath.FromSlash(strings.TrimPrefix(providerRoot, "/")))) + plan.stateDirs = append(plan.stateDirs, localRoot) + plan.stateFiles = append(plan.stateFiles, legacyStateFile) + plan.stateSources = append(plan.stateSources, providerDisconnectStateSource{ + stateFile: legacyStateFile, + localDir: localRoot, + remoteRoot: "/", + }) + } + return plan, nil +} + +func workspaceMountStateFilesForRecord(record workspaceRecord, scope mountscope.Scope) ([]string, error) { + // Mounts key private state by the runtime workspace ID passed to Syncer. + // Delegated Cloud records persist that identity as RelayWorkspaceID, while + // older/direct records may have used ID or Name. Inspect every distinct + // identity that can have keyed this record rather than guessing an order. + workspaceIDs := []string{record.RelayWorkspaceID, record.ID, record.Name} + seenIDs := map[string]struct{}{} + seenPaths := map[string]struct{}{} + var stateFiles []string + for _, workspaceID := range workspaceIDs { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + continue + } + if _, ok := seenIDs[workspaceID]; ok { + continue + } + seenIDs[workspaceID] = struct{}{} + stateFile, err := workspaceMountStateFile(workspaceID, record, scope) + if err != nil { + return nil, err + } + stateFile = filepath.Clean(stateFile) + if _, ok := seenPaths[stateFile]; ok { + continue + } + info, err := os.Stat(stateFile) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return nil, fmt.Errorf("inspect mount state %s: %w", stateFile, err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("mount state path %s is not a regular file", stateFile) + } + seenPaths[stateFile] = struct{}{} + stateFiles = append(stateFiles, stateFile) + } + return stateFiles, nil +} + +func preflightProviderDisconnect(record workspaceRecord, provider string) error { + _, err := prepareProviderDisconnect(record, provider) + return err +} + +func prepareProviderDisconnect(record workspaceRecord, provider string) (providerDisconnectPlan, error) { + if strings.TrimSpace(record.LocalDir) == "" { + return providerDisconnectPlan{}, nil + } + plan, err := planProviderDisconnect(record, provider) + if err != nil { + return providerDisconnectPlan{}, fmt.Errorf("resolve mount state before disconnecting %s: %w", provider, err) + } + running, stalePID, err := runningMountDaemons(record.LocalDir, record.ID, record.Name) + if err != nil { + return providerDisconnectPlan{}, fmt.Errorf("check running mounts before disconnecting %s: %w", provider, err) + } + if len(running) > 0 { + return providerDisconnectPlan{}, fmt.Errorf( + "refusing to disconnect %s while workspace %s has a running mount (%s); stop the mount before disconnecting", + provider, + firstNonBlank(record.Name, record.ID), + formatDaemonPIDs(running), + ) + } + if stalePID != 0 { + return providerDisconnectPlan{}, fmt.Errorf( + "refusing to disconnect %s while workspace %s has unverified mount state (pid %d); confirm the mount is stopped and clear %s before disconnecting", + provider, + firstNonBlank(record.Name, record.ID), + stalePID, + mountPIDFile(record.LocalDir), + ) + } + if err := refuseProviderDisconnectWithPendingState(provider, plan); err != nil { + return providerDisconnectPlan{}, err + } + if err := captureProviderDisconnectDeletions(&plan); err != nil { + return providerDisconnectPlan{}, fmt.Errorf("snapshot local files before disconnecting %s: %w", provider, err) + } + return plan, nil +} + +func removeProviderMirror(record workspaceRecord, provider string) error { + plan, err := prepareProviderDisconnect(record, provider) if err != nil { return err } - for _, entry := range entries { - if entry.Name() == mountscope.RuntimeTopLevel { + return removeProviderMirrorWithPlan(plan) +} + +func removeProviderMirrorWithPlan(plan providerDisconnectPlan) error { + // Verify the complete allow-list before deleting any entry. The plan is a + // snapshot, not a rule: paths created after preflight are never selected. + for _, deletion := range plan.deletions { + kind, fingerprint, err := providerDisconnectPathIdentity(deletion.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return err + } + if kind != deletion.kind || fingerprint != deletion.fingerprint { + return fmt.Errorf( + "refusing local disconnect cleanup because %s changed after preflight", + deletion.path, + ) + } + } + for _, deletion := range plan.deletions { + err := os.Remove(deletion.path) + if deletion.kind == "directory" && errors.Is(err, syscall.ENOTEMPTY) { + // A child created after preflight is intentionally absent from the + // deletion allow-list. Preserve that arrival and keep committing + // the validated cleanup instead of stranding the disconnect after + // the Cloud DELETE has already succeeded. continue } - path := filepath.Join(root, entry.Name()) - if entry.IsDir() { - if err := removeScopedProviderContent(path); err != nil { + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + return nil +} + +func captureProviderDisconnectDeletions(plan *providerDisconnectPlan) error { + if plan == nil { + return nil + } + seen := map[string]struct{}{} + addTree := func(root string, includeRoot, preserveInfrastructure bool) error { + root = filepath.Clean(strings.TrimSpace(root)) + if root == "." { + return nil + } + return filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + if path == root && errors.Is(err, os.ErrNotExist) { + return nil + } return err } - // Remove now-empty mirror directories, but retain any directory - // that still contains a nested runtime tree. - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, syscall.ENOTEMPTY) { - return err + if path == root && !includeRoot { + return nil } - continue + if preserveInfrastructure && path != root { + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return relErr + } + topLevel := strings.SplitN(rel, string(os.PathSeparator), 2)[0] + if topLevel == ".relay" || mountscope.IsInfrastructureTopLevelAt(root, topLevel) { + if entry.IsDir() && rel == topLevel { + return filepath.SkipDir + } + return nil + } + } + cleaned := filepath.Clean(path) + if _, ok := seen[cleaned]; ok { + return nil + } + kind, fingerprint, identityErr := providerDisconnectPathIdentity(cleaned) + if identityErr != nil { + return identityErr + } + seen[cleaned] = struct{}{} + plan.deletions = append(plan.deletions, providerDisconnectDeletion{ + path: cleaned, + kind: kind, + fingerprint: fingerprint, + }) + return nil + }) + } + for _, scopeDir := range plan.cleanScopeDirs { + if err := addTree(scopeDir, false, true); err != nil { + return err } - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + } + for _, subtree := range plan.removeSubtrees { + if err := addTree(subtree, true, false); err != nil { return err } } + sort.Slice(plan.deletions, func(i, j int) bool { + leftDepth := strings.Count(filepath.Clean(plan.deletions[i].path), string(os.PathSeparator)) + rightDepth := strings.Count(filepath.Clean(plan.deletions[j].path), string(os.PathSeparator)) + if leftDepth != rightDepth { + return leftDepth > rightDepth + } + return plan.deletions[i].path > plan.deletions[j].path + }) return nil } +func providerDisconnectPathIdentity(path string) (string, string, error) { + info, err := os.Lstat(path) + if err != nil { + return "", "", err + } + switch { + case info.IsDir(): + return "directory", "", nil + case info.Mode().IsRegular(): + hash, hashErr := hashLocalWritebackFile(path) + return "file", hash, hashErr + case info.Mode()&os.ModeSymlink != 0: + target, readErr := os.Readlink(path) + if readErr != nil { + return "", "", readErr + } + sum := sha256.Sum256([]byte(target)) + return "symlink", hex.EncodeToString(sum[:]), nil + default: + return "", "", fmt.Errorf("unsupported local entry %s (%s)", path, info.Mode()) + } +} + +func refuseProviderDisconnectWithPendingState(provider string, plan providerDisconnectPlan) error { + if len(plan.unknownStateRoots) > 0 { + return fmt.Errorf( + "refusing to disconnect %s because private mount state is unknown for %s; restore the mount state or remount before disconnecting", + provider, + strings.Join(plan.unknownStateRoots, ", "), + ) + } + seen := map[string]struct{}{} + providerRoot := mountscope.NormalizePath("/" + providerRootDir(provider)) + wholeProviderDir := map[string]struct{}{} + for _, localDir := range plan.wholeProviderDirs { + wholeProviderDir[filepath.Clean(strings.TrimSpace(localDir))] = struct{}{} + } + outbox, conflicts, deadLetters, privatePending := 0, 0, 0, 0 + for _, localDir := range plan.stateDirs { + cleaned := filepath.Clean(strings.TrimSpace(localDir)) + if cleaned == "." { + continue + } + if _, ok := seen[cleaned]; ok { + continue + } + seen[cleaned] = struct{}{} + runtimeRoot := filepath.Join(cleaned, ".relay") + _, wholeDir := wholeProviderDir[cleaned] + if wholeDir { + for _, state := range []string{"pending", "failed", "attention"} { + count := countFilesInDir(filepath.Join(runtimeRoot, "outbox", state)) + if count.Err != nil { + return fmt.Errorf("inspect outbox before disconnecting %s: %w", provider, count.Err) + } + outbox += count.Count + } + count := countFilesInDir(filepath.Join(runtimeRoot, "conflicts")) + if count.Err != nil { + return fmt.Errorf("inspect conflicts before disconnecting %s: %w", provider, count.Err) + } + conflicts += count.Count + } else { + for _, state := range []string{"pending", "failed", "attention"} { + count, err := countProviderOutboxRecords(filepath.Join(runtimeRoot, "outbox", state), providerRoot) + if err != nil { + return fmt.Errorf("inspect outbox before disconnecting %s: %w", provider, err) + } + outbox += count + } + count := countFilesInDir( + filepath.Join(runtimeRoot, "conflicts", filepath.FromSlash(strings.TrimPrefix(providerRoot, "/"))), + ) + if count.Err != nil { + return fmt.Errorf("inspect conflicts before disconnecting %s: %w", provider, count.Err) + } + conflicts += count.Count + } + records, err := readDeadLetterRecords(cleaned) + if err != nil { + return fmt.Errorf("inspect dead letters before disconnecting %s: %w", provider, err) + } + for _, record := range records { + if wholeDir || deadLetterRecordBelongsToProvider(record.Path, providerRoot) { + deadLetters++ + } + } + } + for _, source := range plan.stateSources { + cleaned := filepath.Clean(strings.TrimSpace(source.stateFile)) + if cleaned == "." { + continue + } + if _, ok := seen[cleaned]; ok { + continue + } + seen[cleaned] = struct{}{} + items, err := readPendingWritebackItemsFromState( + "", + source.localDir, + source.remoteRoot, + cleaned, + source.scopedChild, + ) + if err != nil { + return fmt.Errorf("inspect private mount state before disconnecting %s: %w", provider, err) + } + for _, item := range items { + if strings.TrimSpace(item.Path) == "" || mountscope.IsWithin(providerRoot, item.Path) { + privatePending++ + } + } + } + if outbox == 0 && conflicts == 0 && deadLetters == 0 && privatePending == 0 { + return nil + } + return fmt.Errorf( + "refusing to disconnect %s while unsynced local state remains (outbox=%d conflicts=%d deadLetters=%d privatePending=%d); resolve or replay the pending work before disconnecting", + provider, + outbox, + conflicts, + deadLetters, + privatePending, + ) +} + +func deadLetterRecordBelongsToProvider(rawPath, providerRoot string) bool { + paths := deadLetterRetryPaths(rawPath) + if len(paths) == 0 { + // Missing or unparseable ownership cannot be proven unrelated. + return true + } + for _, remotePath := range paths { + if mountscope.IsWithin(providerRoot, remotePath) { + return true + } + } + return false +} + +func countProviderOutboxRecords(dir, providerRoot string) (int, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + return 0, err + } + count := 0 + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + // An entry with an unexpected shape cannot be proven unrelated to + // the provider, so make the destructive preflight refuse. + count++ + continue + } + payload, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return 0, err + } + var record struct { + RemotePath string `json:"remotePath"` + } + if err := json.Unmarshal(payload, &record); err != nil { + // An unreadable record cannot be proven unrelated to the provider. + // Count it conservatively so disconnect refuses instead of deleting + // state whose ownership is unknown. + count++ + continue + } + if strings.TrimSpace(record.RemotePath) == "" || mountscope.IsWithin(providerRoot, record.RemotePath) { + count++ + } + } + return count, nil +} + // providerRootDir maps a provider id to the directory name it occupies // inside the local mirror. The mapping must stay aligned with the // `vfsRoot` values that fallbackIntegrationCatalog and the cloud diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index bb9732ce..72087f7b 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -12,6 +12,7 @@ import ( "net/url" "os" "path/filepath" + "reflect" "strconv" "strings" "sync/atomic" @@ -53,7 +54,7 @@ func TestWorkspaceCreateStoresCatalogEntry(t *testing.T) { } } -func TestResolveCLIRequestedLocalLayoutRefusesAllScopedRoutes(t *testing.T) { +func TestResolveCLIRequestedLocalLayoutAcceptsAllScopedRoutes(t *testing.T) { tests := []struct { name string flagValue string @@ -67,9 +68,9 @@ func TestResolveCLIRequestedLocalLayoutRefusesAllScopedRoutes(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - _, err := resolveCLIRequestedLocalLayout(tc.flagValue, tc.envValue, tc.recordedValue, tc.flagProvided) - if err == nil || !strings.Contains(err.Error(), "operator surfaces") || !strings.Contains(err.Error(), "--local-layout=exact") { - t.Fatalf("expected scoped-layout refusal with exact-layout remedy, got %v", err) + got, err := resolveCLIRequestedLocalLayout(tc.flagValue, tc.envValue, tc.recordedValue, tc.flagProvided) + if err != nil || got != mountscope.LayoutScoped { + t.Fatalf("scoped layout route resolved to %q, err=%v", got, err) } }) } @@ -4483,20 +4484,29 @@ func TestWritebackPushScopesRequireProviderSubtree(t *testing.T) { } } -func TestReadLocalMountCursorHealthAggregatesStateFiles(t *testing.T) { +func TestReadWorkspaceMountCursorHealthUsesPersistedPrivateState(t *testing.T) { localDir := t.TempDir() - if err := os.WriteFile(filepath.Join(localDir, ".relayfile-mount-state.json"), []byte(`{"incrementalReadNotReadySince":{"a":"now"},"incrementalBacklogDraining":false}`), 0o644); err != nil { - t.Fatalf("write public state failed: %v", err) + record := workspaceRecord{ + ID: "ws_demo", + LocalDir: localDir, + LocalLayout: mountscope.LayoutExact, + RemotePaths: []string{"/github"}, + MountStateDir: t.TempDir(), + MountKind: mountsync.MountKindDaemon, + } + scope := workspaceMountScopes(record)[0] + stateFile, err := workspaceMountStateFile("ws_demo", record, scope) + if err != nil { + t.Fatal(err) } - privateDir := filepath.Join(localDir, mountsync.DefaultMountStateDirName) - if err := os.MkdirAll(privateDir, 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(stateFile), 0o755); err != nil { t.Fatalf("mkdir private state failed: %v", err) } - if err := os.WriteFile(filepath.Join(privateDir, "state.json"), []byte(`{"incrementalReadNotReadySince":{"a":"now","b":"now"},"incrementalBacklogDraining":true}`), 0o644); err != nil { + if err := os.WriteFile(stateFile, []byte(`{"incrementalReadNotReadySince":{"a":"now","b":"now"},"incrementalBacklogDraining":true}`), 0o644); err != nil { t.Fatalf("write private state failed: %v", err) } - stuck, backlog := readLocalMountCursorHealth(localDir) + stuck, backlog := readWorkspaceMountCursorHealth("ws_demo", record, scope) if stuck != 2 || !backlog { t.Fatalf("cursor health = %d/%v, want 2/true", stuck, backlog) } @@ -4863,7 +4873,7 @@ func TestWritebackPushPostsBulkAndWritesAckedReceipt(t *testing.T) { if got := stdout.String(); !strings.Contains(got, "Pushed ") || !strings.Contains(got, opID) { t.Fatalf("unexpected stdout: %s", got) } - if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "pending")); got != 0 { + if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "pending")).Count; got != 0 { t.Fatalf("pending receipt count = %d, want 0", got) } ackedDir := filepath.Join(localDir, ".relay", "outbox", "acked") @@ -5036,10 +5046,10 @@ func TestWritebackUpdateCanonicalPathAllowsMissingOperationAndSendsIntent(t *tes if got := stdout.String(); !strings.Contains(got, "Updated ") || !strings.Contains(got, " -> "+remotePath) { t.Fatalf("unexpected stdout: %s", got) } - if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "failed")); got != 0 { + if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "failed")).Count; got != 0 { t.Fatalf("failed receipt count = %d, want 0", got) } - if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "acked")); got != 1 { + if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "acked")).Count; got != 1 { t.Fatalf("acked receipt count = %d, want 1", got) } ackedDir := filepath.Join(localDir, ".relay", "outbox", "acked") @@ -5110,10 +5120,10 @@ func TestWritebackUpdateMissingOperationIDPendingStateNeedsAttention(t *testing. if got := err.Error(); !strings.Contains(got, "cannot be tracked") { t.Fatalf("unexpected error: %v", err) } - if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "failed")); got != 0 { + if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "failed")).Count; got != 0 { t.Fatalf("failed receipt count = %d, want 0", got) } - if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "acked")); got != 0 { + if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "acked")).Count; got != 0 { t.Fatalf("acked receipt count = %d, want 0", got) } pendingDir := filepath.Join(localDir, ".relay", "outbox", "pending") @@ -5167,7 +5177,7 @@ func TestWritebackUpdateRejectsDraftPath(t *testing.T) { t.Fatalf("unexpected error: %v", err) } for _, state := range []string{"pending", "acked", "failed"} { - if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", state)); got != 0 { + if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", state)).Count; got != 0 { t.Fatalf("%s receipt count = %d, want 0", state, got) } } @@ -5275,10 +5285,10 @@ func TestWritebackDeleteRequiresOperationID(t *testing.T) { if !sawDelete.Load() { t.Fatal("expected delete request") } - if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "failed")); got != 1 { + if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "failed")).Count; got != 1 { t.Fatalf("failed receipt count = %d, want 1", got) } - if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "acked")); got != 0 { + if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "acked")).Count; got != 0 { t.Fatalf("acked receipt count = %d, want 0", got) } } @@ -5327,10 +5337,10 @@ func TestWritebackUpdateFailsWhenOperationFails(t *testing.T) { if got := err.Error(); !strings.Contains(got, "writeback operation "+opID+" failed") { t.Fatalf("unexpected error: %v", err) } - if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "failed")); got != 1 { + if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "failed")).Count; got != 1 { t.Fatalf("failed receipt count = %d, want 1", got) } - if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "acked")); got != 0 { + if got := countJSONFiles(filepath.Join(localDir, ".relay", "outbox", "acked")).Count; got != 0 { t.Fatalf("acked receipt count = %d, want 0", got) } } @@ -5718,7 +5728,8 @@ func TestOpsListReadsLocalDeadLetterMirror(t *testing.T) { if err := ensureMirrorLayout(localDir); err != nil { t.Fatalf("ensureMirrorLayout failed: %v", err) } - dlDir := filepath.Join(localDir, ".relay", "dead-letter") + scopedLocalDir := filepath.Join(localDir, "notion") + dlDir := filepath.Join(scopedLocalDir, ".relay", "dead-letter") if err := os.MkdirAll(dlDir, 0o755); err != nil { t.Fatalf("mkdir dead-letter failed: %v", err) } @@ -5727,11 +5738,13 @@ func TestOpsListReadsLocalDeadLetterMirror(t *testing.T) { t.Fatalf("write dead-letter record failed: %v", err) } if _, err := upsertWorkspaceDetails(workspaceRecord{ - Name: "demo", - ID: "ws_demo", - LocalDir: localDir, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - LastUsedAt: time.Now().UTC().Format(time.RFC3339), + Name: "demo", + ID: "ws_demo", + LocalDir: localDir, + RemotePaths: []string{"/notion"}, + LocalLayout: "scoped", + CreatedAt: time.Now().UTC().Format(time.RFC3339), + LastUsedAt: time.Now().UTC().Format(time.RFC3339), }); err != nil { t.Fatalf("upsertWorkspaceDetails failed: %v", err) } @@ -5748,6 +5761,511 @@ func TestOpsListReadsLocalDeadLetterMirror(t *testing.T) { } } +func TestScopedWorkspaceConsumersAggregateChildRuntimeState(t *testing.T) { + localRoot := t.TempDir() + record := workspaceRecord{ + ID: "ws_demo", + LocalDir: localRoot, + LocalLayout: "scoped", + RemotePaths: []string{"/github", "/slack"}, + MountStateDir: t.TempDir(), + MountKind: mountsync.MountKindDaemon, + } + scopes := workspaceMountScopes(record) + if len(scopes) != 2 { + t.Fatalf("workspaceMountScopes() = %#v, want 2 scopes", scopes) + } + for index, scope := range scopes { + if err := os.MkdirAll(filepath.Join(scope.LocalDir, ".relay", "dead-letter"), 0o755); err != nil { + t.Fatal(err) + } + reconcileAt := []string{ + "2026-07-30T03:00:00.1Z", + "2026-07-30T03:00:00.12Z", + }[index] + state := fmt.Sprintf( + `{"status":"scope-%d","lastReconcileAt":"%s","lastSuccessfulReconcileAt":"%s","pendingWriteback":99,"failedWritebacks":%d,"stallReason":"scope-%d stalled","incrementalReadNotReadySince":{"event":"now"},"bootstrap":{"phase":"pull","filesSynced":%d,"filesTotal":10}}`, + index+1, + reconcileAt, + reconcileAt, + index+3, + index+1, + index+2, + ) + if err := os.WriteFile(filepath.Join(scope.LocalDir, ".relay", "state.json"), []byte(state), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(scope.LocalDir, ".relay", "dead-letter", fmt.Sprintf("op_%d.json", index+1)), + []byte(fmt.Sprintf(`{"opId":"op_%d","path":"%s/file.md"}`, index+1, scope.RemotePath)), + 0o644, + ); err != nil { + t.Fatal(err) + } + stateFile, err := workspaceMountStateFile("ws_demo", record, scope) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(stateFile), 0o755); err != nil { + t.Fatal(err) + } + pendingField := `"dirty":true` + if index == 1 { + pendingField = `"deletePending":true` + } + privateState := fmt.Sprintf( + `{"files":{"%s/pending.md":{%s}},"incrementalReadNotReadySince":{"private":"now"}}`, + scope.RemotePath, + pendingField, + ) + if err := os.WriteFile(stateFile, []byte(privateState), 0o644); err != nil { + t.Fatal(err) + } + conflictDir := filepath.Join(scope.LocalDir, ".relay", "conflicts") + if err := os.MkdirAll(conflictDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(conflictDir, "conflict.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(scope.LocalDir, ".relay", "permissions-denied.log"), []byte("one\n"), 0o644); err != nil { + t.Fatal(err) + } + outboxDir := filepath.Join(scope.LocalDir, ".relay", "outbox", "pending") + if err := os.MkdirAll(outboxDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outboxDir, "pending.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + } + for state, name := range map[string]string{ + "pending": "compat-pending.json", + "failed": "compat-failed.json", + "acked": "compat-acked.json", + } { + dir := filepath.Join(localRoot, ".relay", "outbox", state) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + } + + writeback, err := buildWritebackStatusReport("ws_demo", record) + if err != nil { + t.Fatal(err) + } + if writeback.Pending != 2 || writeback.Failed != 7 || len(writeback.DeadLettered) != 2 { + t.Fatalf("scoped writeback report = %#v, want private pending=2 failed=7 dead-lettered=2", writeback) + } + + snapshot := buildWorkspaceSyncStateSnapshot(syncStatusResponse{}, "ws_demo", record) + if snapshot.PendingWriteback != 2 || snapshot.PendingConflicts != 2 || snapshot.DeniedPaths != 2 { + t.Fatalf("scoped sync snapshot = %#v, want pending/conflicts/denied = 2/2/2", snapshot) + } + if snapshot.FailedWritebacks != 7 { + t.Fatalf("failed writebacks = %d, want 7", snapshot.FailedWritebacks) + } + if snapshot.Bootstrap == nil || snapshot.Bootstrap.FilesSynced != 5 || snapshot.Bootstrap.FilesTotal != 20 { + t.Fatalf("aggregated bootstrap = %#v, want files 5/20", snapshot.Bootstrap) + } + if got := snapshot.StallReason; got != "scope-1 stalled; scope-2 stalled" { + t.Fatalf("aggregated stall reason = %q", got) + } + + health := buildWorkspaceHealthReport("ws_demo", record) + if health.Status != "scope-1; scope-2" || + health.StuckEventCount != 2 || + health.OutboxPending != 3 || + health.OutboxFailed != 1 || + health.OutboxAcked != 1 { + t.Fatalf("scoped health report = %#v, want child states and child/catalog queues", health) + } + if health.LastReconcileAt != "2026-07-30T03:00:00.12Z" || + health.LastSuccessfulReconcileAt != "2026-07-30T03:00:00.12Z" { + t.Fatalf( + "reconcile timestamps = %q/%q, want later variable-width fraction", + health.LastReconcileAt, + health.LastSuccessfulReconcileAt, + ) + } + if err := os.WriteFile( + filepath.Join(scopes[0].LocalDir, ".relay", "state.json"), + []byte(`{"lastReconcileAt":"invalid","lastSuccessfulReconcileAt":"invalid"}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + unknownHealth := buildWorkspaceHealthReport("ws_demo", record) + if unknownHealth.LastReconcileAt != "" || unknownHealth.LastSuccessfulReconcileAt != "" { + t.Fatalf( + "malformed child timestamps produced aggregate claims %q/%q, want unknown", + unknownHealth.LastReconcileAt, + unknownHealth.LastSuccessfulReconcileAt, + ) + } + + compatibilityDir := filepath.Join(localRoot, ".relay", "dead-letter") + if err := os.MkdirAll(compatibilityDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(compatibilityDir, "op_2.json"), + []byte(`{"opId":"op_2","path":"/github/stale.md"}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + path, localDir, _, err := findWorkspaceDeadLetterRecord(record, "op_2") + if err != nil { + t.Fatal(err) + } + if localDir != scopes[1].LocalDir || path != filepath.Join(scopes[1].LocalDir, ".relay", "dead-letter", "op_2.json") { + t.Fatalf("scoped retry lookup = %q in %q, want authoritative second scope instead of catalog duplicate", path, localDir) + } +} + +func TestWorkspaceMountScopesRecoversLegacyExactRuntimeRoot(t *testing.T) { + localDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(localDir, ".relay"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(localDir, ".relay", "state.json"), + []byte(`{"remoteRoot":"/github"}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + record := workspaceRecord{LocalDir: localDir, RemotePaths: []string{"/slack"}} + scopes := workspaceMountScopes(record) + if len(scopes) != 1 || scopes[0].RemotePath != "/github" || scopes[0].LocalDir != localDir { + t.Fatalf("legacy exact scopes = %#v, want persisted runtime root /github", scopes) + } + remoteRoot, err := workspaceRemoteRootForLocalDir(record, localDir) + if err != nil { + t.Fatal(err) + } + if remoteRoot != "/github" { + t.Fatalf("legacy exact retry root = %q, want persisted runtime root /github", remoteRoot) + } +} + +func TestWorkspaceRetryTargetRoutesCatalogCompatibilityRecordToScopedChild(t *testing.T) { + localRoot := t.TempDir() + record := workspaceRecord{ + LocalDir: localRoot, + LocalLayout: mountscope.LayoutScoped, + RemotePaths: []string{"/github", "/slack/channels/project"}, + } + localDir, remoteRoot, err := workspaceRetryTarget(record, localRoot, "/slack/channels/project/message.json") + if err != nil { + t.Fatal(err) + } + if want := mountscope.LocalDir(localRoot, "/slack/channels/project"); localDir != want || remoteRoot != "/slack/channels/project" { + t.Fatalf("retry target = %q at %q, want %q at /slack/channels/project", localDir, remoteRoot, want) + } +} + +func TestWorkspaceStateDirForDeadLetterPathsKeepsCrossScopeAtCatalogRoot(t *testing.T) { + localRoot := t.TempDir() + record := workspaceRecord{ + LocalDir: localRoot, + LocalLayout: mountscope.LayoutScoped, + RemotePaths: []string{"/github", "/slack"}, + } + for _, rawPaths := range []string{ + "/github/repos/acme/issue.json,/slack/channels/project/message.json", + "/slack/channels/project/message.json,/github/repos/acme/issue.json", + } { + if got := workspaceStateDirForDeadLetterPaths(record, rawPaths); got != localRoot { + t.Fatalf("cross-scope dead-letter dir for %q = %q, want catalog root %q", rawPaths, got, localRoot) + } + } + if got, want := workspaceStateDirForDeadLetterPaths( + record, + "/github/repos/acme/issue.json,/github/repos/acme/pr.json", + ), mountscope.LocalDir(localRoot, "/github"); got != want { + t.Fatalf("single-scope dead-letter dir = %q, want %q", got, want) + } +} + +func TestWorkspaceDeadLetterRetryGroupsRoutesEveryScopedRoot(t *testing.T) { + localRoot := t.TempDir() + record := workspaceRecord{ + LocalDir: localRoot, + LocalLayout: mountscope.LayoutScoped, + RemotePaths: []string{"/github", "/slack/channels/project"}, + } + for _, remotePath := range []string{ + "/github/repos/acme/cloud/issue.json", + "/github/repos/acme/cloud/pr.json", + "/slack/channels/project/message.json", + } { + scope, ok := workspaceMountScopeForRemotePath(record, remotePath) + if !ok { + t.Fatalf("missing test scope for %s", remotePath) + } + relative := strings.TrimPrefix(remotePath, scope.RemotePath) + localPath := filepath.Join(scope.LocalDir, filepath.FromSlash(strings.TrimPrefix(relative, "/"))) + if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(localPath, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + } + tests := []struct { + name string + rawPaths string + want []workspaceDeadLetterRetryGroup + }{ + { + name: "allowlist order", + rawPaths: "/github/repos/acme/cloud/issue.json,/slack/channels/project/message.json", + want: []workspaceDeadLetterRetryGroup{ + { + LocalDir: mountscope.LocalDir(localRoot, "/github"), + RemoteRoot: "/github", + Paths: []string{"/github/repos/acme/cloud/issue.json"}, + }, + { + LocalDir: mountscope.LocalDir(localRoot, "/slack/channels/project"), + RemoteRoot: "/slack/channels/project", + Paths: []string{"/slack/channels/project/message.json"}, + }, + }, + }, + { + name: "reverse order", + rawPaths: "/slack/channels/project/message.json,/github/repos/acme/cloud/issue.json", + want: []workspaceDeadLetterRetryGroup{ + { + LocalDir: mountscope.LocalDir(localRoot, "/slack/channels/project"), + RemoteRoot: "/slack/channels/project", + Paths: []string{"/slack/channels/project/message.json"}, + }, + { + LocalDir: mountscope.LocalDir(localRoot, "/github"), + RemoteRoot: "/github", + Paths: []string{"/github/repos/acme/cloud/issue.json"}, + }, + }, + }, + { + name: "same root stays one retry", + rawPaths: "/github/repos/acme/cloud/issue.json,/github/repos/acme/cloud/pr.json", + want: []workspaceDeadLetterRetryGroup{ + { + LocalDir: mountscope.LocalDir(localRoot, "/github"), + RemoteRoot: "/github", + Paths: []string{ + "/github/repos/acme/cloud/issue.json", + "/github/repos/acme/cloud/pr.json", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := workspaceDeadLetterRetryGroups(record, localRoot, tt.rawPaths) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("retry groups = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestWorkspaceDeadLetterRetryGroupsRejectsInvalidPathBeforeRetry(t *testing.T) { + localRoot := t.TempDir() + record := workspaceRecord{ + LocalDir: localRoot, + LocalLayout: mountscope.LayoutScoped, + RemotePaths: []string{"/github", "/slack"}, + } + githubPath := filepath.Join(mountscope.LocalDir(localRoot, "/github"), "repos", "acme", "cloud", "issue.json") + if err := os.MkdirAll(filepath.Dir(githubPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(githubPath, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + _, err := workspaceDeadLetterRetryGroups( + record, + localRoot, + "/github/repos/acme/cloud/issue.json,/linear/issues/ENG-123.json", + ) + if err == nil || !strings.Contains(err.Error(), "outside the persisted scoped allowlist") { + t.Fatalf("retry groups error = %v, want scoped allowlist refusal", err) + } +} + +func TestWorkspaceDeadLetterRetryGroupsIgnoreCompatibilityRecordStorageRoot(t *testing.T) { + localRoot := t.TempDir() + record := workspaceRecord{ + LocalDir: localRoot, + LocalLayout: mountscope.LayoutScoped, + RemotePaths: []string{"/github", "/slack"}, + } + for _, remotePath := range []string{ + "/github/repos/acme/cloud/issue.json", + "/slack/channels/project/message.json", + } { + scope, ok := workspaceMountScopeForRemotePath(record, remotePath) + if !ok { + t.Fatalf("missing test scope for %s", remotePath) + } + relative := strings.TrimPrefix(strings.TrimPrefix(remotePath, scope.RemotePath), "/") + localPath := filepath.Join(scope.LocalDir, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(localPath, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + } + githubChild := mountscope.LocalDir(localRoot, "/github") + groups, err := workspaceDeadLetterRetryGroups( + record, + githubChild, + "/github/repos/acme/cloud/issue.json,/slack/channels/project/message.json", + ) + if err != nil { + t.Fatal(err) + } + if len(groups) != 2 || + groups[0].RemoteRoot != "/github" || + groups[1].RemoteRoot != "/slack" || + groups[1].LocalDir != mountscope.LocalDir(localRoot, "/slack") { + t.Fatalf("retry groups from child-stored compatibility record = %#v, want both scoped roots", groups) + } +} + +func TestWorkspaceSyncStatusJSONMakesTopologyClaimsExclusive(t *testing.T) { + snapshot := syncStateFile{ + WorkspaceID: "ws_demo", + RemoteRoot: "/github", + Mode: defaultMountMode, + } + tests := []struct { + name string + record workspaceRecord + wantLayout string + wantRoot bool + wantRoots bool + }{ + { + name: "exact", + record: workspaceRecord{LocalLayout: mountscope.LayoutExact, RemotePaths: []string{"/github"}}, + wantLayout: mountscope.LayoutExact, + wantRoot: true, + }, + { + name: "scoped", + record: workspaceRecord{LocalLayout: mountscope.LayoutScoped, RemotePaths: []string{"/github", "/slack"}}, + wantLayout: mountscope.LayoutScoped, + wantRoots: true, + }, + { + name: "legacy unknown", + record: workspaceRecord{}, + wantLayout: "unknown", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload, err := json.Marshal(workspaceSyncStatusJSON(snapshot, tt.record)) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(payload, &got); err != nil { + t.Fatal(err) + } + if got["localLayout"] != tt.wantLayout { + t.Fatalf("localLayout = %v, want %q; payload=%s", got["localLayout"], tt.wantLayout, payload) + } + _, hasRoot := got["remoteRoot"] + _, hasRoots := got["remoteRoots"] + if hasRoot != tt.wantRoot || hasRoots != tt.wantRoots { + t.Fatalf("topology fields root=%v roots=%v, want %v/%v; payload=%s", hasRoot, hasRoots, tt.wantRoot, tt.wantRoots, payload) + } + }) + } +} + +func TestWorkspaceSyncStatusExactUsesPersistedRootWithoutReadableRuntimeState(t *testing.T) { + for _, tt := range []struct { + name string + prepareRuntime func(t *testing.T, localDir string) + }{ + {name: "absent"}, + { + name: "unreadable", + prepareRuntime: func(t *testing.T, localDir string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(localDir, ".relay", "state.json"), 0o755); err != nil { + t.Fatal(err) + } + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + localDir := t.TempDir() + if tt.prepareRuntime != nil { + tt.prepareRuntime(t, localDir) + } + record := workspaceRecord{ + LocalDir: localDir, + LocalLayout: mountscope.LayoutExact, + RemotePaths: []string{"/github"}, + } + snapshot := buildWorkspaceSyncStateSnapshot(syncStatusResponse{}, "ws_demo", record) + payload, err := json.Marshal(workspaceSyncStatusJSON(snapshot, record)) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(payload, &got); err != nil { + t.Fatal(err) + } + if got["remoteRoot"] != "/github" { + t.Fatalf("remoteRoot = %v, want persisted /github; payload=%s", got["remoteRoot"], payload) + } + }) + } +} + +func TestSkipStuckAcrossScopesVisitsEachCursorAndSharesLimit(t *testing.T) { + scopes := []mountscope.Scope{ + {RemotePath: "/github", LocalDir: "/tmp/github"}, + {RemotePath: "/slack", LocalDir: "/tmp/slack"}, + } + var calls []string + skipped, backlog, err := skipStuckAcrossScopes(scopes, 3, func(scope mountscope.Scope, max int) (int, bool, error) { + calls = append(calls, fmt.Sprintf("%s:%d", scope.RemotePath, max)) + if scope.RemotePath == "/github" { + return 1, false, nil + } + return 2, false, nil + }) + if err != nil { + t.Fatal(err) + } + if skipped != 3 || backlog { + t.Fatalf("skip result = %d/%v, want 3/false", skipped, backlog) + } + if want := []string{"/github:3", "/slack:2"}; strings.Join(calls, ",") != strings.Join(want, ",") { + t.Fatalf("scope calls = %v, want %v", calls, want) + } +} + func TestOpsListReportsEmptyWhenNoDeadLetterDir(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) @@ -5780,7 +6298,7 @@ func TestOpsListRefreshesMirrorFromServer(t *testing.T) { if err := ensureMirrorLayout(localDir); err != nil { t.Fatalf("ensureMirrorLayout failed: %v", err) } - dlDir := filepath.Join(localDir, ".relay", "dead-letter") + dlDir := filepath.Join(localDir, "notion", ".relay", "dead-letter") if err := os.MkdirAll(dlDir, 0o755); err != nil { t.Fatalf("mkdir dead-letter failed: %v", err) } @@ -5789,11 +6307,13 @@ func TestOpsListRefreshesMirrorFromServer(t *testing.T) { t.Fatalf("write stale record failed: %v", err) } if _, err := upsertWorkspaceDetails(workspaceRecord{ - Name: "demo", - ID: "ws_demo", - LocalDir: localDir, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - LastUsedAt: time.Now().UTC().Format(time.RFC3339), + Name: "demo", + ID: "ws_demo", + LocalDir: localDir, + RemotePaths: []string{"/notion"}, + LocalLayout: "scoped", + CreatedAt: time.Now().UTC().Format(time.RFC3339), + LastUsedAt: time.Now().UTC().Format(time.RFC3339), }); err != nil { t.Fatalf("upsertWorkspaceDetails failed: %v", err) } @@ -5848,7 +6368,7 @@ func TestOpsListRefreshesMirrorFromServer(t *testing.T) { } } -func TestOpsListRefreshRejectsUnsafeOpIDAndKeepsSidecar(t *testing.T) { +func TestOpsListRefreshRejectsUnsafeOpIDWithoutMutatingMirror(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) @@ -5902,26 +6422,219 @@ func TestOpsListRefreshRejectsUnsafeOpIDAndKeepsSidecar(t *testing.T) { if err := run([]string{"ops", "list", "--workspace", "demo"}, strings.NewReader(""), &stdout, &stdout); err != nil { t.Fatalf("run ops list failed: %v", err) } + if !strings.Contains(stdout.String(), "unsafe or missing opId") { + t.Fatalf("refresh warning = %q, want unsafe opId refusal", stdout.String()) + } // The traversal op id must not escape the dead-letter directory. traversal := filepath.Join(localDir, "pwn.json") if _, err := os.Stat(traversal); !os.IsNotExist(err) { t.Fatalf("unsafe opId escaped dead-letter dir: %s exists (err=%v)", traversal, err) } - if _, err := os.Stat(filepath.Join(dlDir, "op_keep.json")); err != nil { - t.Fatalf("expected op_keep payload written: %v", err) + if _, err := os.Stat(filepath.Join(dlDir, "op_keep.json")); !os.IsNotExist(err) { + t.Fatalf("invalid response partially wrote op_keep payload, err=%v", err) } - // Sidecar for a still-live op must be preserved, not pruned as if it - // were a standalone payload named "op_keep.error". + // No local mutation is allowed when any response item is invalid. if _, err := os.Stat(filepath.Join(dlDir, "op_keep.error.json")); err != nil { t.Fatalf("expected op_keep.error.json preserved: %v", err) } - // Stale payload and its sidecar must both be removed. - if _, err := os.Stat(filepath.Join(dlDir, "op_old.json")); !os.IsNotExist(err) { - t.Fatalf("expected stale op_old.json pruned, err=%v", err) + if _, err := os.Stat(filepath.Join(dlDir, "op_old.json")); err != nil { + t.Fatalf("invalid response pruned op_old.json, err=%v", err) + } + if _, err := os.Stat(filepath.Join(dlDir, "op_old.error.json")); err != nil { + t.Fatalf("invalid response pruned op_old.error.json, err=%v", err) + } +} + +func TestOpsListRefreshMissingItemsPreservesMirror(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + localDir := t.TempDir() + deadLetterDir := filepath.Join(localDir, ".relay", "dead-letter") + if err := os.MkdirAll(deadLetterDir, 0o755); err != nil { + t.Fatal(err) } - if _, err := os.Stat(filepath.Join(dlDir, "op_old.error.json")); !os.IsNotExist(err) { - t.Fatalf("expected stale op_old.error.json pruned, err=%v", err) + recordPath := filepath.Join(deadLetterDir, "op_local.json") + if err := os.WriteFile(recordPath, []byte(`{"opId":"op_local"}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := upsertWorkspaceDetails(workspaceRecord{ + Name: "demo", + ID: "ws_demo", + LocalDir: localDir, + }); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"nextCursor":null}`)) + })) + defer server.Close() + if err := saveCredentials(credentials{Server: server.URL, Token: "token"}); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + if err := run([]string{"ops", "list", "--workspace", "demo"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "missing required items array") { + t.Fatalf("refresh output = %q, want missing-items refusal", stdout.String()) + } + if _, err := os.Stat(recordPath); err != nil { + t.Fatalf("missing-items response pruned local record: %v", err) + } +} + +func TestOpsListRefreshFetchesEveryPageBeforePruning(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + localDir := t.TempDir() + deadLetterDir := filepath.Join(localDir, ".relay", "dead-letter") + if err := os.MkdirAll(deadLetterDir, 0o755); err != nil { + t.Fatal(err) + } + stalePath := filepath.Join(deadLetterDir, "op_stale.json") + if err := os.WriteFile(stalePath, []byte(`{"opId":"op_stale"}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := upsertWorkspaceDetails(workspaceRecord{ + Name: "demo", + ID: "ws_demo", + LocalDir: localDir, + }); err != nil { + t.Fatal(err) + } + + firstPage := make([]opsListItem, 200) + for i := range firstPage { + firstPage[i] = opsListItem{ + OpID: fmt.Sprintf("op_page_one_%03d", i), + Path: fmt.Sprintf("/notion/page-%03d.json", i), + Status: "dead_lettered", + } + } + nextCursor := "page-two" + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if got := r.URL.Query().Get("limit"); got != "200" { + t.Fatalf("limit = %q, want 200", got) + } + w.Header().Set("Content-Type", "application/json") + switch cursor := r.URL.Query().Get("cursor"); cursor { + case "": + if err := json.NewEncoder(w).Encode(opsListResponse{ + Items: firstPage, + NextCursor: &nextCursor, + }); err != nil { + t.Fatalf("encode first page: %v", err) + } + case nextCursor: + if err := json.NewEncoder(w).Encode(opsListResponse{Items: []opsListItem{{ + OpID: "op_page_two", + Path: "/notion/page-two.json", + Status: "dead_lettered", + }}}); err != nil { + t.Fatalf("encode second page: %v", err) + } + default: + t.Fatalf("unexpected cursor %q", cursor) + } + })) + defer server.Close() + if err := saveCredentials(credentials{Server: server.URL, Token: "token"}); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + if err := run([]string{"ops", "list", "--workspace", "demo"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatal(err) + } + if requests != 2 { + t.Fatalf("requests = %d, want two pages", requests) + } + if _, err := os.Stat(filepath.Join(deadLetterDir, "op_page_one_000.json")); err != nil { + t.Fatalf("page-one record missing: %v", err) + } + if _, err := os.Stat(filepath.Join(deadLetterDir, "op_page_two.json")); err != nil { + t.Fatalf("page-two record missing: %v", err) + } + if _, err := os.Stat(stalePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale record was not pruned after the complete feed validated: %v", err) + } +} + +func TestOpsListRefreshLaterPageFailureOrCursorLoopPreservesMirror(t *testing.T) { + for _, test := range []struct { + name string + secondPage func(http.ResponseWriter) + want string + }{ + { + name: "later page failure", + secondPage: func(w http.ResponseWriter) { + http.Error(w, "try again", http.StatusBadGateway) + }, + want: "502", + }, + { + name: "repeated cursor", + secondPage: func(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[],"nextCursor":"page-two"}`)) + }, + want: "repeated cursor", + }, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + localDir := t.TempDir() + deadLetterDir := filepath.Join(localDir, ".relay", "dead-letter") + if err := os.MkdirAll(deadLetterDir, 0o755); err != nil { + t.Fatal(err) + } + localPath := filepath.Join(deadLetterDir, "op_local.json") + if err := os.WriteFile(localPath, []byte(`{"opId":"op_local"}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := upsertWorkspaceDetails(workspaceRecord{ + Name: "demo", ID: "ws_demo", LocalDir: localDir, + }); err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("cursor") == "page-two" { + test.secondPage(w) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[{"opId":"op_page_one","path":"/notion/page-one.json","status":"dead_lettered"}],"nextCursor":"page-two"}`)) + })) + defer server.Close() + if err := saveCredentials(credentials{Server: server.URL, Token: "token"}); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + if err := run([]string{"ops", "list", "--workspace", "demo"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), test.want) { + t.Fatalf("refresh output = %q, want %q", stdout.String(), test.want) + } + if _, err := os.Stat(localPath); err != nil { + t.Fatalf("later-page failure pruned local record: %v", err) + } + if _, err := os.Stat(filepath.Join(deadLetterDir, "op_page_one.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("partial feed mutated local mirror: %v", err) + } + }) } } @@ -6211,7 +6924,7 @@ func TestOpsReplayPostsToCloudAndRemovesLocalRecord(t *testing.T) { if err := ensureMirrorLayout(localDir); err != nil { t.Fatalf("ensureMirrorLayout failed: %v", err) } - dlDir := filepath.Join(localDir, ".relay", "dead-letter") + dlDir := filepath.Join(localDir, "github", ".relay", "dead-letter") if err := os.MkdirAll(dlDir, 0o755); err != nil { t.Fatalf("mkdir dead-letter failed: %v", err) } @@ -6220,13 +6933,15 @@ func TestOpsReplayPostsToCloudAndRemovesLocalRecord(t *testing.T) { t.Fatalf("write dead-letter record failed: %v", err) } if _, err := upsertWorkspaceDetails(workspaceRecord{ - Name: "demo", - ID: "ws_demo", - LocalDir: localDir, - AgentName: "relayfile-cli", - Scopes: append([]string(nil), defaultJoinScopes...), - CreatedAt: time.Now().UTC().Format(time.RFC3339), - LastUsedAt: time.Now().UTC().Format(time.RFC3339), + Name: "demo", + ID: "ws_demo", + LocalDir: localDir, + RemotePaths: []string{"/github"}, + LocalLayout: "scoped", + AgentName: "relayfile-cli", + Scopes: append([]string(nil), defaultJoinScopes...), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + LastUsedAt: time.Now().UTC().Format(time.RFC3339), }); err != nil { t.Fatalf("upsertWorkspaceDetails failed: %v", err) } @@ -7483,8 +8198,11 @@ func TestIntegrationAdoptClearsDisconnectMarker(t *testing.T) { // must clear that marker so the status probe stops reporting the // workspace as disconnected — otherwise the operator gets a stale // "disconnected" reading for a workspace they just adopted into. - _, localDir := setupAdoptWorkspace(t) - if err := markProviderDisconnected(localDir, "github", mountscope.LayoutExact); err != nil { + record, localDir := setupAdoptWorkspace(t) + if err := os.WriteFile(filepath.Join(localDir, mountsync.LegacyMountStateFileName), []byte(`{"files":{}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := markProviderDisconnected(record, "github"); err != nil { t.Fatalf("markProviderDisconnected failed: %v", err) } markerPath := filepath.Join(localDir, ".relay", "disconnected", "github.json") @@ -7512,28 +8230,565 @@ func TestIntegrationAdoptClearsDisconnectMarker(t *testing.T) { } } -func TestMarkProviderDisconnectedScopedPreservesRuntimeState(t *testing.T) { - localDir := t.TempDir() - child := filepath.Join(localDir, "github") - child = filepath.Join(child, "repos", "acme", "cloud") - runtime := filepath.Join(child, mountscope.RuntimeTopLevel) - if err := os.MkdirAll(filepath.Join(runtime, "outbox"), 0o755); err != nil { - t.Fatalf("create runtime state: %v", err) +func TestMarkProviderDisconnectedPreservesScopedRuntimeState(t *testing.T) { + localRoot := t.TempDir() + record := workspaceRecord{ + ID: "ws_demo", + LocalDir: localRoot, + LocalLayout: mountscope.LayoutScoped, + MountStateDir: t.TempDir(), + RemotePaths: []string{ + "/github/repos/acme", + "/slack/channels/project", + }, + } + githubScope := mountscope.LocalDir(localRoot, "/github/repos/acme") + githubMirror := filepath.Join(githubScope, "README.md") + githubOutbox := filepath.Join(githubScope, ".relay", "outbox", "pending", "queued.json") + githubConflict := filepath.Join(githubScope, ".relay", "conflicts", "README.md.local") + githubDeadLetter := filepath.Join(githubScope, ".relay", "dead-letter", "op_dead.json") + githubInfrastructure := filepath.Join(githubScope, ".git", "config") + slackMirror := filepath.Join(mountscope.LocalDir(localRoot, "/slack/channels/project"), "topic.md") + var githubStateFile string + for _, scope := range workspaceMountScopes(record) { + stateFile, err := workspaceMountStateFile(record.ID, record, scope) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(stateFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(stateFile, []byte(`{"files":{}}`), 0o600); err != nil { + t.Fatal(err) + } + if scope.RemotePath == "/github/repos/acme" { + githubStateFile = stateFile + } + } + for path, content := range map[string]string{ + githubMirror: "mirrored", + githubOutbox: "queued", + githubConflict: "unresolved", + githubDeadLetter: `{"opId":"op_dead"}`, + githubInfrastructure: "[core]\n", + slackMirror: "unrelated", + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } } - if err := os.WriteFile(filepath.Join(runtime, "outbox", "pending.json"), []byte("pending"), 0o644); err != nil { - t.Fatalf("write pending state: %v", err) + githubHash, err := hashLocalWritebackFile(githubMirror) + if err != nil { + t.Fatal(err) } - if err := os.WriteFile(filepath.Join(child, "README.md"), []byte("mirrored"), 0o644); err != nil { - t.Fatalf("write mirrored content: %v", err) + githubState := fmt.Sprintf( + `{"files":{"/github/repos/acme/README.md":{"hash":%q}}}`, + githubHash, + ) + if err := os.WriteFile(githubStateFile, []byte(githubState), 0o600); err != nil { + t.Fatal(err) } - if err := markProviderDisconnected(localDir, "github", mountscope.LayoutScoped); err != nil { - t.Fatalf("markProviderDisconnected failed: %v", err) + + err = markProviderDisconnected(record, "github") + if err == nil || + !strings.Contains(err.Error(), "outbox=1") || + !strings.Contains(err.Error(), "conflicts=1") || + !strings.Contains(err.Error(), "deadLetters=1") { + t.Fatalf("expected actionable pending-state refusal, got %v", err) + } + for _, path := range []string{githubMirror, githubOutbox, githubConflict, githubDeadLetter, githubInfrastructure, slackMirror} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected refused disconnect to preserve %s: %v", path, err) + } + } + if _, err := os.Stat(filepath.Join(localRoot, ".relay", "disconnected", "github.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected refused disconnect not to write marker, got %v", err) + } + + for _, path := range []string{githubOutbox, githubConflict, githubDeadLetter} { + if err := os.Remove(path); err != nil { + t.Fatalf("clear pending state %s: %v", path, err) + } + } + if err := markProviderDisconnected(record, "github"); err != nil { + t.Fatalf("markProviderDisconnected after clearing pending state failed: %v", err) + } + if _, err := os.Stat(githubMirror); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected github mirror content removed, got %v", err) + } + if payload, err := os.ReadFile(githubInfrastructure); err != nil || string(payload) != "[core]\n" { + t.Fatalf("expected excluded github infrastructure preserved, payload=%q err=%v", payload, err) + } + if payload, err := os.ReadFile(slackMirror); err != nil || string(payload) != "unrelated" { + t.Fatalf("expected unrelated scoped mirror preserved, payload=%q err=%v", payload, err) + } + markerPath := filepath.Join(localRoot, ".relay", "disconnected", "github.json") + if _, err := os.Stat(markerPath); err != nil { + t.Fatalf("expected common-root disconnect marker: %v", err) + } +} + +func TestProviderDisconnectPreflightAllowsCloudOnlyWorkspace(t *testing.T) { + record := workspaceRecord{ID: "ws_cloud_only", Name: "cloud-only"} + plan, err := planProviderDisconnect(record, "github") + if err != nil { + t.Fatal(err) + } + if len(plan.cleanScopeDirs) != 0 || len(plan.removeSubtrees) != 0 || len(plan.stateDirs) != 0 || + len(plan.wholeProviderDirs) != 0 || len(plan.stateFiles) != 0 || len(plan.unknownStateRoots) != 0 { + t.Fatalf("cloud-only disconnect produced local cleanup plan: %#v", plan) + } + if err := preflightProviderDisconnect(record, "github"); err != nil { + t.Fatalf("cloud-only disconnect preflight = %v, want no local gate", err) + } +} + +func TestIntegrationDisconnectRefusesBeforeCloudMutationWhenScopedStateIsPending(t *testing.T) { + record, localRoot := setupAdoptWorkspace(t) + record.RelayWorkspaceID = "ws_relay_runtime" + record.LocalLayout = mountscope.LayoutScoped + record.RemotePaths = []string{"/github/repos/acme"} + record.MountStateDir = t.TempDir() + record.mountStateSet = true + if _, err := upsertWorkspaceDetails(record); err != nil { + t.Fatalf("persist scoped workspace: %v", err) + } + githubScope := mountscope.LocalDir(localRoot, "/github/repos/acme") + githubMirror := filepath.Join(githubScope, "README.md") + if err := os.MkdirAll(filepath.Dir(githubMirror), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(githubMirror), err) + } + if err := os.WriteFile(githubMirror, []byte("mirrored"), 0o644); err != nil { + t.Fatalf("write %s: %v", githubMirror, err) + } + stateFile, err := workspaceMountStateFile(record.RelayWorkspaceID, record, workspaceMountScopes(record)[0]) + if err != nil { + t.Fatalf("resolve private state: %v", err) + } + if err := os.MkdirAll(filepath.Dir(stateFile), 0o755); err != nil { + t.Fatalf("mkdir private state: %v", err) + } + if err := os.WriteFile(stateFile, []byte(`{"files":{"/github/repos/acme/README.md":{"dirty":true},"/github/repos/acme/removed.md":{"deletePending":true}}}`), 0o644); err != nil { + t.Fatalf("write private state: %v", err) + } + + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + t.Fatalf("refused disconnect reached Cloud: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + err = runIntegrationDisconnect( + []string{"github", "--workspace", "demo", "--yes", "--cloud-api-url", server.URL}, + strings.NewReader(""), + io.Discard, + ) + if err == nil || !strings.Contains(err.Error(), "privatePending=2") { + t.Fatalf("expected private pending-state refusal, got %v", err) + } + if requests != 0 { + t.Fatalf("Cloud requests = %d, want 0", requests) + } + for _, path := range []string{githubMirror, stateFile} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected refused disconnect to preserve %s: %v", path, err) + } + } +} + +func TestIntegrationDisconnectRefusesUnobservedLocalDriftBeforeCloudMutation(t *testing.T) { + record, localRoot := setupAdoptWorkspace(t) + record.RelayWorkspaceID = "ws_relay_runtime" + record.LocalLayout = mountscope.LayoutScoped + record.RemotePaths = []string{"/github/repos/acme"} + record.MountStateDir = t.TempDir() + record.mountStateSet = true + if _, err := upsertWorkspaceDetails(record); err != nil { + t.Fatalf("persist scoped workspace: %v", err) + } + scope := workspaceMountScopes(record)[0] + trackedPath := filepath.Join(scope.LocalDir, "README.md") + untrackedPath := filepath.Join(scope.LocalDir, "draft.md") + if err := os.MkdirAll(scope.LocalDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(trackedPath, []byte("original"), 0o644); err != nil { + t.Fatal(err) + } + originalHash, err := hashLocalWritebackFile(trackedPath) + if err != nil { + t.Fatal(err) + } + stateFile, err := workspaceMountStateFile(record.RelayWorkspaceID, record, scope) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(stateFile), 0o755); err != nil { + t.Fatal(err) + } + privateState := fmt.Sprintf( + `{"files":{"/github/repos/acme/README.md":{"hash":%q}}}`, + originalHash, + ) + if err := os.WriteFile(stateFile, []byte(privateState), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(trackedPath, []byte("edited while stopped"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(untrackedPath, []byte("new local work"), 0o644); err != nil { + t.Fatal(err) + } + + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + t.Fatalf("drifted disconnect reached Cloud: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + err = runIntegrationDisconnect( + []string{"github", "--workspace", "demo", "--yes", "--cloud-api-url", server.URL}, + strings.NewReader(""), + io.Discard, + ) + if err == nil || !strings.Contains(err.Error(), "privatePending=2") { + t.Fatalf("expected tracked and untracked local drift refusal, got %v", err) + } + if requests != 0 { + t.Fatalf("Cloud requests = %d, want 0", requests) + } + for _, path := range []string{trackedPath, untrackedPath, stateFile} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("refused disconnect deleted local work %s: %v", path, err) + } + } + if _, err := os.Stat(filepath.Join(localRoot, ".relay", "disconnected", "github.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("refused disconnect wrote marker: %v", err) + } +} + +func TestIntegrationDisconnectCommitsPrevalidatedPlanAfterCloudMutation(t *testing.T) { + record, localRoot := setupAdoptWorkspace(t) + record.RelayWorkspaceID = "ws_relay_runtime" + record.LocalLayout = mountscope.LayoutScoped + record.RemotePaths = []string{"/github/repos/acme"} + record.MountStateDir = t.TempDir() + record.mountStateSet = true + if _, err := upsertWorkspaceDetails(record); err != nil { + t.Fatalf("persist scoped workspace: %v", err) + } + scope := workspaceMountScopes(record)[0] + mirrorPath := filepath.Join(scope.LocalDir, "README.md") + if err := os.MkdirAll(filepath.Dir(mirrorPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mirrorPath, []byte("mirrored"), 0o644); err != nil { + t.Fatal(err) + } + mirrorHash, err := hashLocalWritebackFile(mirrorPath) + if err != nil { + t.Fatal(err) + } + plannedDir := filepath.Join(scope.LocalDir, "nested") + nestedMirrorPath := filepath.Join(plannedDir, "mirrored.md") + if err := os.MkdirAll(plannedDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(nestedMirrorPath, []byte("nested mirror"), 0o644); err != nil { + t.Fatal(err) } - if _, err := os.Stat(filepath.Join(runtime, "outbox", "pending.json")); err != nil { - t.Fatalf("scoped disconnect deleted runtime state: %v", err) + nestedMirrorHash, err := hashLocalWritebackFile(nestedMirrorPath) + if err != nil { + t.Fatal(err) + } + stateFile, err := workspaceMountStateFile(record.RelayWorkspaceID, record, scope) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(stateFile), 0o755); err != nil { + t.Fatal(err) + } + state := fmt.Sprintf( + `{"files":{"/github/repos/acme/README.md":{"hash":%q},"/github/repos/acme/nested/mirrored.md":{"hash":%q}}}`, + mirrorHash, + nestedMirrorHash, + ) + if err := os.WriteFile(stateFile, []byte(state), 0o600); err != nil { + t.Fatal(err) + } + + deleteCalls := 0 + latePath := filepath.Join(scope.LocalDir, "created-after-preflight.md") + nestedLatePath := filepath.Join(plannedDir, "created-after-preflight.md") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || + r.URL.Path != "/api/v1/workspaces/ws_123/integrations/github/status" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + deleteCalls++ + // The preflighted cursor can disappear after Cloud commits. Local + // cleanup must consume the already-validated plan instead of turning + // that post-mutation change into a second refusal. + if err := os.Remove(stateFile); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(latePath, []byte("created after preflight"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(nestedLatePath, []byte("nested after preflight"), 0o644); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + pointAdoptCloudCredentials(t, server.URL) + + var stdout bytes.Buffer + err = runIntegrationDisconnect( + []string{"github", "--workspace", "demo", "--yes", "--cloud-api-url", server.URL}, + strings.NewReader(""), + &stdout, + ) + if err != nil { + t.Fatalf("disconnect after successful Cloud mutation = %v", err) + } + if deleteCalls != 1 { + t.Fatalf("Cloud DELETE calls = %d, want 1", deleteCalls) + } + if _, err := os.Stat(mirrorPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("prevalidated mirror cleanup did not run: %v", err) + } + if payload, err := os.ReadFile(latePath); err != nil || string(payload) != "created after preflight" { + t.Fatalf("post-preflight file was not preserved, payload=%q err=%v", payload, err) + } + if _, err := os.Stat(nestedMirrorPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("prevalidated nested mirror cleanup did not run: %v", err) + } + if payload, err := os.ReadFile(nestedLatePath); err != nil || string(payload) != "nested after preflight" { + t.Fatalf("nested post-preflight file was not preserved, payload=%q err=%v", payload, err) + } + if _, err := os.Stat(filepath.Join(localRoot, ".relay", "disconnected", "github.json")); err != nil { + t.Fatalf("disconnect marker missing after Cloud mutation: %v", err) + } +} + +func TestProviderDisconnectCleanupRefusesChangedPlannedFile(t *testing.T) { + scopeDir := t.TempDir() + path := filepath.Join(scopeDir, "README.md") + if err := os.WriteFile(path, []byte("observed"), 0o644); err != nil { + t.Fatal(err) + } + plan := providerDisconnectPlan{cleanScopeDirs: []string{scopeDir}} + if err := captureProviderDisconnectDeletions(&plan); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("changed after preflight"), 0o644); err != nil { + t.Fatal(err) + } + err := removeProviderMirrorWithPlan(plan) + if err == nil || !strings.Contains(err.Error(), "changed after preflight") { + t.Fatalf("changed planned file cleanup = %v, want refusal", err) + } + if payload, readErr := os.ReadFile(path); readErr != nil || string(payload) != "changed after preflight" { + t.Fatalf("changed planned file was deleted, payload=%q err=%v", payload, readErr) + } +} + +func TestProviderDisconnectPreflightIgnoresOtherProvidersInBroadScope(t *testing.T) { + localRoot := t.TempDir() + record := workspaceRecord{ + ID: "ws_cloud", + RelayWorkspaceID: "ws_runtime", + LocalDir: localRoot, + LocalLayout: mountscope.LayoutExact, + RemotePaths: []string{"/"}, + MountStateDir: t.TempDir(), + MountKind: mountsync.MountKindDaemon, + } + scope := workspaceMountScopes(record)[0] + stateFile, err := workspaceMountStateFile(record.RelayWorkspaceID, record, scope) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(stateFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + stateFile, + []byte(`{"files":{"/slack/channels/project/message.json":{"dirty":true}}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + outboxDir := filepath.Join(localRoot, ".relay", "outbox", "pending") + if err := os.MkdirAll(outboxDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(outboxDir, "slack.json"), + []byte(`{"remotePath":"/slack/channels/project/message.json"}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + conflictPath := filepath.Join(localRoot, ".relay", "conflicts", "slack", "channels", "project", "message.json.rev.local") + if err := os.MkdirAll(filepath.Dir(conflictPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(conflictPath, []byte("conflict"), 0o644); err != nil { + t.Fatal(err) + } + deadLetterDir := filepath.Join(localRoot, ".relay", "dead-letter") + if err := os.MkdirAll(deadLetterDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(deadLetterDir, "slack-op.json"), + []byte(`{"opId":"slack-op","path":"/slack/channels/project/message.json"}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + if err := preflightProviderDisconnect(record, "github"); err != nil { + t.Fatalf("GitHub disconnect was blocked by unrelated Slack state: %v", err) + } + if err := os.WriteFile( + filepath.Join(deadLetterDir, "mixed-op.json"), + []byte(`{"opId":"mixed-op","path":"/slack/channels/project/other.json,/github/repos/acme/pending.json"}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + err = preflightProviderDisconnect(record, "github") + if err == nil || !strings.Contains(err.Error(), "deadLetters=1") { + t.Fatalf("GitHub disconnect with second-position bulk dead letter = %v, want refusal", err) + } +} + +func TestProviderDisconnectPreflightInspectsScopedCatalogCompatibilityState(t *testing.T) { + localRoot := t.TempDir() + record := workspaceRecord{ + ID: "ws_cloud", + RelayWorkspaceID: "ws_runtime", + LocalDir: localRoot, + LocalLayout: mountscope.LayoutScoped, + RemotePaths: []string{"/github", "/slack"}, + MountStateDir: t.TempDir(), + MountKind: mountsync.MountKindDaemon, + } + for _, scope := range workspaceMountScopes(record) { + stateFile, err := workspaceMountStateFile(record.RelayWorkspaceID, record, scope) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(stateFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(stateFile, []byte(`{"files":{}}`), 0o644); err != nil { + t.Fatal(err) + } + } + deadLetterDir := filepath.Join(localRoot, ".relay", "dead-letter") + if err := os.MkdirAll(deadLetterDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(deadLetterDir, "mixed-op.json"), + []byte(`{"opId":"mixed-op","path":"/slack/channels/project/message.json,/github/repos/acme/pending.json"}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + err := preflightProviderDisconnect(record, "github") + if err == nil || !strings.Contains(err.Error(), "deadLetters=1") { + t.Fatalf("scoped catalog compatibility dead letter preflight = %v, want refusal", err) + } +} + +func TestProviderDisconnectPreflightRefusesUnreadableConflictShape(t *testing.T) { + localRoot := t.TempDir() + record := workspaceRecord{ + ID: "ws_cloud", + RelayWorkspaceID: "ws_runtime", + LocalDir: localRoot, + LocalLayout: mountscope.LayoutScoped, + RemotePaths: []string{"/github"}, + MountStateDir: t.TempDir(), + MountKind: mountsync.MountKindDaemon, + } + scope := workspaceMountScopes(record)[0] + stateFile, err := workspaceMountStateFile(record.RelayWorkspaceID, record, scope) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(stateFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(stateFile, []byte(`{"files":{}}`), 0o644); err != nil { + t.Fatal(err) + } + conflictsPath := filepath.Join(scope.LocalDir, ".relay", "conflicts") + if err := os.MkdirAll(filepath.Dir(conflictsPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(conflictsPath, []byte("unexpected non-directory"), 0o644); err != nil { + t.Fatal(err) + } + + err = preflightProviderDisconnect(record, "github") + if err == nil || !strings.Contains(err.Error(), "inspect conflicts before disconnecting github") { + t.Fatalf("unreadable conflict shape preflight = %v, want refusal", err) + } + if payload, readErr := os.ReadFile(conflictsPath); readErr != nil || string(payload) != "unexpected non-directory" { + t.Fatalf("refused preflight mutated conflict path, payload=%q err=%v", payload, readErr) + } +} + +func TestObservedFileCountsCarryUnreadableState(t *testing.T) { + path := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(path, []byte("opaque"), 0o644); err != nil { + t.Fatal(err) + } + for name, observed := range map[string]observedFileCount{ + "json": countJSONFiles(path), + "all": countFilesInDir(path), + } { + if observed.Err == nil { + t.Fatalf("%s count = %#v, want incompleteness error", name, observed) + } + } +} + +func TestProviderDisconnectPreflightRefusesUnknownPrivateState(t *testing.T) { + record := workspaceRecord{ + ID: "ws_cloud", + RelayWorkspaceID: "ws_runtime", + LocalDir: t.TempDir(), + LocalLayout: mountscope.LayoutExact, + RemotePaths: []string{"/github"}, + MountStateDir: t.TempDir(), + MountKind: mountsync.MountKindDaemon, + } + err := preflightProviderDisconnect(record, "github") + if err == nil || !strings.Contains(err.Error(), "private mount state is unknown") { + t.Fatalf("missing private state preflight = %v, want explicit unknown-state refusal", err) + } +} + +func TestDeadLetterRecordBelongsToProviderChecksEveryBulkPath(t *testing.T) { + for _, rawPath := range []string{ + "/github/repos/acme/pending.json,/slack/channels/project/other.json", + "/slack/channels/project/other.json,/github/repos/acme/pending.json", + } { + if !deadLetterRecordBelongsToProvider(rawPath, "/github") { + t.Fatalf("mixed-provider dead letter %q did not match GitHub", rawPath) + } } - if _, err := os.Stat(filepath.Join(child, "README.md")); !os.IsNotExist(err) { - t.Fatalf("expected mirrored content removed, stat err=%v", err) + if deadLetterRecordBelongsToProvider("/slack/channels/project/other.json", "/github") { + t.Fatal("Slack-only dead letter matched GitHub") } } diff --git a/cmd/relayfile-cli/writeback_list.go b/cmd/relayfile-cli/writeback_list.go index 90c09ee1..60817442 100644 --- a/cmd/relayfile-cli/writeback_list.go +++ b/cmd/relayfile-cli/writeback_list.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/agentworkforce/relayfile/internal/mountscope" + "github.com/agentworkforce/relayfile/internal/mountsync" ) const writebackListUsage = "usage: relayfile writeback list --state pending|dead [--workspace WS] [--json]" @@ -67,7 +68,7 @@ func runWritebackList(args []string, stdout io.Writer) error { if err != nil { return err } - items, err := listLocalWritebackItems(workspaceID, record.LocalDir, normalizedState) + items, err := listWorkspaceWritebackItems(workspaceID, record, normalizedState) if err != nil { return err } @@ -78,6 +79,55 @@ func runWritebackList(args []string, stdout io.Writer) error { return nil } +func listWorkspaceWritebackItems(workspaceID string, record workspaceRecord, state string) ([]writebackListItem, error) { + itemsByKey := map[string]writebackListItem{} + if state == "pending" { + for _, scope := range workspaceMountScopes(record) { + stateFile, err := workspaceMountStateFile(workspaceID, record, scope) + if err != nil { + return nil, err + } + remoteRoot, err := workspaceRemoteRootForLocalDir(record, scope.LocalDir) + if err != nil { + return nil, err + } + items, err := readPendingWritebackItemsFromState( + workspaceID, + scope.LocalDir, + remoteRoot, + stateFile, + strings.TrimSpace(record.LocalLayout) == mountscope.LayoutScoped, + ) + if err != nil { + return nil, err + } + for _, item := range items { + key := firstNonBlank(strings.TrimSpace(item.ID), strings.TrimSpace(item.Path)) + itemsByKey[key] = item + } + } + } else { + // Include the catalog root for compatibility with dead letters written + // before scoped topology was persisted, then each active child root. + for _, localDir := range workspaceStateDirs(record) { + items, err := readDeadWritebackItems(workspaceID, localDir) + if err != nil { + return nil, err + } + for _, item := range items { + key := firstNonBlank(strings.TrimSpace(item.ID), strings.TrimSpace(item.Path)) + itemsByKey[key] = item + } + } + } + items := make([]writebackListItem, 0, len(itemsByKey)) + for _, item := range itemsByKey { + items = append(items, item) + } + sortWritebackListItems(items) + return items, nil +} + func validWritebackListState(state string) bool { switch state { case "pending", "dead": @@ -87,33 +137,11 @@ func validWritebackListState(state string) bool { } } -// listLocalWritebackItems returns per-operation writeback rows for the given -// state. `pending` is sourced from dirty tracked files in -// `/.relayfile-mount-state.json`; `dead` is sourced from per-op -// records under `/.relay/dead-letter/`. Aggregate counters in -// `.relay/state.json` are deliberately not expanded into synthetic rows. -func listLocalWritebackItems(workspaceID, localDir, state string) ([]writebackListItem, error) { - if strings.TrimSpace(localDir) == "" { - return []writebackListItem{}, nil - } - if state == "dead" { - return readDeadWritebackItems(workspaceID, localDir) - } - return readPendingWritebackItems(workspaceID, localDir) -} - -func readPendingWritebackItems(workspaceID, localDir string) ([]writebackListItem, error) { +func readPendingWritebackItemsFromState(workspaceID, localDir, remoteRoot, stateFile string, scopedChild bool) ([]writebackListItem, error) { var state struct { - Files map[string]struct { - Revision string `json:"revision"` - Hash string `json:"hash"` - Dirty bool `json:"dirty"` - Denied bool `json:"denied"` - WriteDenied bool `json:"writeDenied"` - ReadOnly bool `json:"readonly"` - } `json:"files"` + Files map[string]mountsync.TrackedFileState `json:"files"` } - payload, err := os.ReadFile(filepath.Join(localDir, ".relayfile-mount-state.json")) + payload, err := os.ReadFile(stateFile) if err != nil { if os.IsNotExist(err) { return []writebackListItem{}, nil @@ -123,8 +151,7 @@ func readPendingWritebackItems(workspaceID, localDir string) ([]writebackListIte if err := json.Unmarshal(payload, &state); err != nil { return nil, fmt.Errorf("invalid mount state: %w", err) } - remoteRoot := readMountRemoteRoot(localDir) - localHashes, err := localWritebackHashes(localDir, remoteRoot, false) + localHashes, err := localWritebackHashes(localDir, remoteRoot, scopedChild) if err != nil { return nil, err } @@ -137,7 +164,7 @@ func readPendingWritebackItems(workspaceID, localDir string) ([]writebackListIte continue } localHash, hasLocal := localHashes[path] - pending := tracked.Dirty + pending := tracked.HasPendingWriteback() if !pending && !tracked.Denied && !tracked.WriteDenied { switch { case hasLocal && tracked.Hash != "" && localHash != tracked.Hash: @@ -179,7 +206,17 @@ func readPendingWritebackItems(workspaceID, localDir string) ([]writebackListIte func localWritebackHashes(localDir, remoteRoot string, scopedChild bool) (map[string]string, error) { hashes := map[string]string{} - err := filepath.WalkDir(localDir, func(path string, entry os.DirEntry, err error) error { + info, err := os.Stat(localDir) + if err != nil { + if os.IsNotExist(err) { + return hashes, nil + } + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("local writeback root %s is not a directory", localDir) + } + err = filepath.WalkDir(localDir, func(path string, entry os.DirEntry, err error) error { if err != nil { return err } diff --git a/cmd/relayfile-cli/writeback_list_test.go b/cmd/relayfile-cli/writeback_list_test.go index bfe2d2ca..c130eb5b 100644 --- a/cmd/relayfile-cli/writeback_list_test.go +++ b/cmd/relayfile-cli/writeback_list_test.go @@ -8,8 +8,74 @@ import ( "strings" "testing" "time" + + "github.com/agentworkforce/relayfile/internal/mountsync" ) +func TestListWorkspaceWritebackItemsAggregatesScopedChildren(t *testing.T) { + localRoot := t.TempDir() + record := workspaceRecord{ + ID: "ws_demo", + LocalDir: localRoot, + LocalLayout: "scoped", + RemotePaths: []string{"/github", "/slack"}, + MountStateDir: t.TempDir(), + MountKind: mountsync.MountKindDaemon, + } + for index, scope := range workspaceMountScopes(record) { + relayDir := filepath.Join(scope.LocalDir, ".relay") + if err := os.MkdirAll(filepath.Join(relayDir, "dead-letter"), 0o755); err != nil { + t.Fatal(err) + } + stateFile, err := workspaceMountStateFile("ws_demo", record, scope) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(stateFile), 0o755); err != nil { + t.Fatal(err) + } + trackedState := `"dirty":true` + if index == 1 { + // A queued delete has no local file and no hash. The authoritative + // deletePending bit must still surface as a pending writeback. + trackedState = `"deletePending":true` + } + tracked := `{"files":{"` + scope.RemotePath + `/draft.md":{` + trackedState + `}}}` + if err := os.WriteFile(stateFile, []byte(tracked), 0o644); err != nil { + t.Fatal(err) + } + if index == 0 { + if err := os.WriteFile(filepath.Join(scope.LocalDir, "draft.md"), []byte("local draft"), 0o644); err != nil { + t.Fatal(err) + } + } + dead := `{"opId":"op_` + scope.RemotePath[1:] + `","path":"` + scope.RemotePath + `/failed.md"}` + if err := os.WriteFile( + filepath.Join(relayDir, "dead-letter", "op_"+scope.RemotePath[1:]+".json"), + []byte(dead), + 0o644, + ); err != nil { + t.Fatal(err) + } + } + + pending, err := listWorkspaceWritebackItems("ws_demo", record, "pending") + if err != nil { + t.Fatal(err) + } + if len(pending) != 2 || pending[0].Path != "/github/draft.md" || pending[1].Path != "/slack/draft.md" { + t.Fatalf("scoped pending items = %#v", pending) + } + + dead, err := listWorkspaceWritebackItems("ws_demo", record, "dead") + if err != nil { + t.Fatal(err) + } + if len(dead) != 2 || dead[0].Path != "/github/failed.md" || dead[1].Path != "/slack/failed.md" { + t.Fatalf("scoped dead items = %#v", dead) + } +} + // writebackListSDKItem mirrors WritebackItem from // packages/sdk/typescript/src/types.ts. Field names MUST stay in sync with the // SDK; this struct is the load-bearing assertion that the CLI emits an @@ -203,7 +269,7 @@ func TestWritebackListPendingUsesRemoteRootForNonRootMount(t *testing.T) { t.Fatalf("write mount state failed: %v", err) } writeWritebackListState(t, localDir, syncStateFile{WorkspaceID: "ws_demo", RemoteRoot: "/notion"}) - upsertWritebackListWorkspace(t, localDir) + upsertWritebackListWorkspace(t, localDir, "/notion") var out bytes.Buffer if err := run([]string{"writeback", "list", "--state", "pending", "--workspace", "demo", "--json"}, strings.NewReader(""), &out, &out); err != nil { @@ -249,6 +315,61 @@ func TestLocalWritebackHashesIncludesCatalogNamedProviderContentForScopedChild(t } } +func TestLocalWritebackHashesTreatsMissingScopeAsEmpty(t *testing.T) { + localDir := filepath.Join(t.TempDir(), "not-materialized") + hashes, err := localWritebackHashes(localDir, "/github", true) + if err != nil { + t.Fatal(err) + } + if len(hashes) != 0 { + t.Fatalf("missing scope hashes = %#v, want empty", hashes) + } +} + +func TestWritebackListPendingUsesCatalogRootWithoutPublicSnapshot(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + localDir := t.TempDir() + if err := os.WriteFile( + filepath.Join(localDir, mountsync.LegacyMountStateFileName), + []byte(`{"files":{"/notion/pages/page-1.json":{"dirty":true}}}`), + 0o600, + ); err != nil { + t.Fatal(err) + } + record := workspaceRecord{ + ID: "ws_demo", + LocalDir: localDir, + LocalLayout: "exact", + RemotePaths: []string{"/notion"}, + } + items, err := listWorkspaceWritebackItems("ws_demo", record, "pending") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Path != "/notion/pages/page-1.json" { + t.Fatalf("catalog-root pending items = %#v", items) + } +} + +func TestWritebackListPendingRefusesUnknownLegacyExactRoot(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + localDir := t.TempDir() + if err := os.WriteFile( + filepath.Join(localDir, mountsync.LegacyMountStateFileName), + []byte(`{"files":{"/notion/pages/page-1.json":{"dirty":true}}}`), + 0o600, + ); err != nil { + t.Fatal(err) + } + _, err := listWorkspaceWritebackItems("ws_demo", workspaceRecord{ + ID: "ws_demo", + LocalDir: localDir, + }, "pending") + if err == nil || !strings.Contains(err.Error(), "mount root is unknown") { + t.Fatalf("expected unknown legacy root refusal, got %v", err) + } +} + func TestWritebackListPendingSkipsReadonlyTrackedFiles(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) @@ -491,14 +612,19 @@ func TestWritebackListDeadJSONMergesErrorSidecar(t *testing.T) { } } -func upsertWritebackListWorkspace(t *testing.T, localDir string) { +func upsertWritebackListWorkspace(t *testing.T, localDir string, remoteRoots ...string) { t.Helper() + if len(remoteRoots) == 0 { + remoteRoots = []string{"/"} + } if _, err := upsertWorkspaceDetails(workspaceRecord{ - Name: "demo", - ID: "ws_demo", - LocalDir: localDir, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - LastUsedAt: time.Now().UTC().Format(time.RFC3339), + Name: "demo", + ID: "ws_demo", + LocalDir: localDir, + LocalLayout: "exact", + RemotePaths: remoteRoots, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + LastUsedAt: time.Now().UTC().Format(time.RFC3339), }); err != nil { t.Fatalf("upsertWorkspaceDetails failed: %v", err) } diff --git a/cmd/relayfile-mount/main.go b/cmd/relayfile-mount/main.go index 9fe316f7..e55b6919 100644 --- a/cmd/relayfile-mount/main.go +++ b/cmd/relayfile-mount/main.go @@ -90,7 +90,7 @@ func main() { pathsFile := flag.String("paths-file", strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_PATHS_FILE")), "file containing remote root paths, as JSON array or newline-separated list") eventProvider := flag.String("provider", strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_PROVIDER")), "event provider filter") localDir := flag.String("local-dir", strings.TrimSpace(os.Getenv("RELAYFILE_LOCAL_DIR")), "local mirror directory") - localLayout := flag.String("local-layout", envOrDefault("RELAYFILE_MOUNT_LOCAL_LAYOUT", localLayoutExact), "local directory layout: exact (scoped layout is temporarily unavailable until operator surfaces are ready)") + localLayout := flag.String("local-layout", envOrDefault("RELAYFILE_MOUNT_LOCAL_LAYOUT", localLayoutExact), "local directory layout: exact (local-dir is mirror root) or scoped (remote path is appended under local-dir)") stateFile := flag.String("state-file", strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_STATE_FILE")), "state file path") stateDir := flag.String("state-dir", envOrDefault("RELAYFILE_MOUNT_STATE_DIR", mountsync.DefaultMountStateDir()), "directory for private mount state") mountKind := flag.String("mount-kind", envOrDefault("RELAYFILE_MOUNT_KIND", mountsync.MountKindDaemon), "private state identity kind: daemon, flush, or initial-sync") @@ -232,14 +232,9 @@ func resolveLocalLayout(layout string) (string, error) { return mountscope.ResolveLayout(layout) } -// Scoped runtime state is implemented below this CLI boundary, but its -// operator surfaces are not yet complete. Refuse the user-facing capability -// until status/list/retry can see every scoped child state location. func validateCLIRequestedLocalLayout(layout string) error { - if layout == localLayoutScoped { - return fmt.Errorf("--local-layout=%s is temporarily unavailable until scoped operator surfaces are ready; use --local-layout=%s", localLayoutScoped, localLayoutExact) - } - return nil + _, err := mountscope.ResolveLayout(layout) + return err } func resolveSyncMode(mode string) (string, error) { @@ -264,9 +259,24 @@ func executeMount(rootCtx context.Context, cfg mountConfig, runPoll pollRunner, if len(remotePaths) == 0 { remotePaths = []string{cfg.remotePath} } - if len(mountscope.NormalizePaths(remotePaths, "/")) > 1 { + scopes, err := mountscope.Plan( + cfg.localDir, + cfg.localLayout, + remotePaths, + cfg.remotePath, + cfg.stateFile, + ) + if err != nil { + return err + } + if len(scopes) > 1 { return fmt.Errorf("multiple --remote-path values are not supported with --mode=%s; use --mode=%s", mountModeFuse, mountModePoll) } + scope := scopes[0] + cfg.remotePath = scope.RemotePath + cfg.remotePaths = []string{scope.RemotePath} + cfg.localDir = scope.LocalDir + cfg.scopedChild = cfg.localLayout == localLayoutScoped return runFuse(rootCtx, cfg) default: return fmt.Errorf("unsupported mount mode %q", cfg.mode) diff --git a/cmd/relayfile-mount/main_test.go b/cmd/relayfile-mount/main_test.go index f502d7fa..fd49add7 100644 --- a/cmd/relayfile-mount/main_test.go +++ b/cmd/relayfile-mount/main_test.go @@ -208,13 +208,11 @@ func TestResolveLocalLayout(t *testing.T) { } } -func TestValidateCLIRequestedLocalLayoutRefusesScopedUntilOperatorSurfacesReady(t *testing.T) { - if err := validateCLIRequestedLocalLayout(localLayoutExact); err != nil { - t.Fatalf("exact layout should remain available: %v", err) - } - err := validateCLIRequestedLocalLayout(localLayoutScoped) - if err == nil || !strings.Contains(err.Error(), "operator surfaces") || !strings.Contains(err.Error(), "--local-layout=exact") { - t.Fatalf("expected scoped-layout refusal with exact-layout remedy, got %v", err) +func TestValidateCLIRequestedLocalLayoutAcceptsSupportedLayouts(t *testing.T) { + for _, layout := range []string{localLayoutExact, localLayoutScoped} { + if err := validateCLIRequestedLocalLayout(layout); err != nil { + t.Fatalf("supported layout %q should remain available: %v", layout, err) + } } } @@ -303,6 +301,42 @@ func TestExecuteMountDispatchesFuseMode(t *testing.T) { } } +func TestExecuteMountPlansSingleScopedFusePathBeforeDispatch(t *testing.T) { + catalogDir := t.TempDir() + cfg := mountConfig{ + mode: mountModeFuse, + localDir: catalogDir, + localLayout: localLayoutScoped, + remotePaths: []string{"/github"}, + } + var received mountConfig + + err := executeMount( + context.Background(), + cfg, + func(context.Context, mountConfig) error { return nil }, + func(_ context.Context, planned mountConfig) error { + received = planned + return nil + }, + ) + if err != nil { + t.Fatalf("executeMount returned error: %v", err) + } + if got, want := received.localDir, filepath.Join(catalogDir, "github"); got != want { + t.Fatalf("FUSE local dir = %q, want scoped child %q", got, want) + } + if received.remotePath != "/github" { + t.Fatalf("FUSE remote path = %q, want /github", received.remotePath) + } + if got := strings.Join(received.remotePaths, ","); got != "/github" { + t.Fatalf("FUSE remote paths = %q, want /github", got) + } + if !received.scopedChild { + t.Fatal("expected FUSE config to be marked as a scoped child") + } +} + func TestExecuteMountRejectsMultipleFusePaths(t *testing.T) { cfg := mountConfig{ mode: mountModeFuse, diff --git a/internal/mountsync/syncer.go b/internal/mountsync/syncer.go index 2f0ea684..8534d865 100644 --- a/internal/mountsync/syncer.go +++ b/internal/mountsync/syncer.go @@ -1428,7 +1428,10 @@ type skippedMaterialization struct { LastError string `json:"lastError"` } -type trackedFile struct { +// TrackedFileState is the persisted per-path cursor shape. It is exported from +// this internal package so operator surfaces can deserialize the writer-owned +// schema instead of maintaining partial lookalike structs. +type TrackedFileState struct { Revision string `json:"revision"` ContentType string `json:"contentType"` Encoding string `json:"encoding,omitempty"` @@ -1453,6 +1456,12 @@ type trackedFile struct { ReadOnly bool `json:"readonly,omitempty"` } +func (tracked TrackedFileState) HasPendingWriteback() bool { + return tracked.Dirty || tracked.DeletePending +} + +type trackedFile = TrackedFileState + type githubWorkingTreeMount struct { Owner string Repo string @@ -7034,7 +7043,7 @@ func (s *Syncer) savePublicState() error { fileStatus = "write-denied" case tracked.Denied: fileStatus = "read-denied" - case tracked.Dirty || tracked.DeletePending: + case tracked.HasPendingWriteback(): fileStatus = "writeback-pending" } if !s.lowMemory { diff --git a/internal/mountsync/syncer_test.go b/internal/mountsync/syncer_test.go index 35307ff6..224575d1 100644 --- a/internal/mountsync/syncer_test.go +++ b/internal/mountsync/syncer_test.go @@ -52,6 +52,21 @@ func markLocalDirtyForTest(t *testing.T, syncer *Syncer, remotePath, localPath s syncer.state.Files[normalizeRemotePath(remotePath)] = tracked } +func TestTrackedFileStateHasPendingWriteback(t *testing.T) { + for name, tracked := range map[string]TrackedFileState{ + "clean": {}, + "dirty": {Dirty: true}, + "delete-pending": {DeletePending: true}, + } { + t.Run(name, func(t *testing.T) { + want := name != "clean" + if got := tracked.HasPendingWriteback(); got != want { + t.Fatalf("HasPendingWriteback() = %v, want %v", got, want) + } + }) + } +} + func TestHTTPClientRetryDelayHonorsRetryAfter(t *testing.T) { client := NewHTTPClient("https://example.test", "token", nil) if got := client.retryDelay(1, "30"); got != 30*time.Second { diff --git a/packages/sdk/typescript/src/mount-launcher.test.ts b/packages/sdk/typescript/src/mount-launcher.test.ts index b99f0c81..c983b8cf 100644 --- a/packages/sdk/typescript/src/mount-launcher.test.ts +++ b/packages/sdk/typescript/src/mount-launcher.test.ts @@ -116,53 +116,76 @@ describe("default mount launcher", () => { } }) - it("refuses direct scoped-layout configuration before filesystem or process side effects", async () => { + it("starts a direct scoped-layout configuration in its scoped child root", async () => { const tempRoot = await mkdtemp( path.join(os.tmpdir(), "relayfile-default-launcher-scoped-layout-") ) const localDir = path.join(tempRoot, "mirror") - const spawnImpl = vi.fn() + const scopedDir = path.join(localDir, "notion") + const child = new FakeChildProcess() + const spawnImpl = vi.fn().mockReturnValue(child as never) const launcher = createDefaultMountLauncher({ spawnImpl }) try { - await expect( - launcher.start({ - env: { - ...createMountEnv(localDir), - RELAYFILE_MOUNT_LOCAL_LAYOUT: "scoped" - }, - readyTimeoutMs: 50 - }) - ).rejects.toMatchObject({ - name: "MountSessionInputError", - code: "mount_session_input_error" + await mkdir(path.join(scopedDir, ".relay"), { recursive: true }) + await writeFile( + path.join(scopedDir, ".relay", "state.json"), + JSON.stringify({ + mode: "poll", + intervalMs: 30_000, + lastReconcileAt: new Date().toISOString(), + providers: [{ status: "ready" }] + }), + "utf8" + ) + const instance = await launcher.start({ + env: { + ...createMountEnv(localDir), + RELAYFILE_MOUNT_LOCAL_LAYOUT: "scoped" + }, + readyTimeoutMs: 1_000 }) - expect(spawnImpl).not.toHaveBeenCalled() - await expect(stat(localDir)).rejects.toMatchObject({ code: "ENOENT" }) + await instance.ready + expect(spawnImpl).toHaveBeenCalledOnce() + expect(spawnImpl.mock.calls[0]?.[2]).toMatchObject({ cwd: scopedDir }) + await instance.stop() } finally { await rm(tempRoot, { recursive: true, force: true }) } }) - it("refuses inherited scoped-layout configuration before filesystem or process side effects", async () => { + it("starts an inherited scoped-layout configuration in its scoped child root", async () => { const tempRoot = await mkdtemp( path.join(os.tmpdir(), "relayfile-default-launcher-inherited-scoped-layout-") ) const localDir = path.join(tempRoot, "mirror") - const spawnImpl = vi.fn() + const scopedDir = path.join(localDir, "notion") + const child = new FakeChildProcess() + const spawnImpl = vi.fn().mockReturnValue(child as never) const launcher = createDefaultMountLauncher({ spawnImpl }) const previousLayout = process.env.RELAYFILE_MOUNT_LOCAL_LAYOUT process.env.RELAYFILE_MOUNT_LOCAL_LAYOUT = "scoped" try { - await expect( - launcher.start({ env: createMountEnv(localDir), readyTimeoutMs: 50 }) - ).rejects.toMatchObject({ - name: "MountSessionInputError", - code: "mount_session_input_error" + await mkdir(path.join(scopedDir, ".relay"), { recursive: true }) + await writeFile( + path.join(scopedDir, ".relay", "state.json"), + JSON.stringify({ + mode: "poll", + intervalMs: 30_000, + lastReconcileAt: new Date().toISOString(), + providers: [{ status: "ready" }] + }), + "utf8" + ) + const instance = await launcher.start({ + env: createMountEnv(localDir), + readyTimeoutMs: 1_000 }) - expect(spawnImpl).not.toHaveBeenCalled() - await expect(stat(localDir)).rejects.toMatchObject({ code: "ENOENT" }) + await instance.ready + expect(spawnImpl).toHaveBeenCalledOnce() + expect(spawnImpl.mock.calls[0]?.[2]).toMatchObject({ cwd: scopedDir }) + await instance.stop() } finally { if (previousLayout === undefined) { delete process.env.RELAYFILE_MOUNT_LOCAL_LAYOUT diff --git a/packages/sdk/typescript/src/mount-launcher.ts b/packages/sdk/typescript/src/mount-launcher.ts index 8759265d..e3b67e41 100644 --- a/packages/sdk/typescript/src/mount-launcher.ts +++ b/packages/sdk/typescript/src/mount-launcher.ts @@ -12,7 +12,6 @@ import path from "node:path" import process from "node:process" import { RelayFileClient } from "./client.js" import { getRelayfileMountBinaryPath } from "./mount-path.js" -import { assertExactMountLayout } from "./mount-layout-guard.js" import { CloudAbortError, MountModeUnavailableError, @@ -110,7 +109,6 @@ async function startRelayfileMount( ...process.env, ...input.env } - assertExactMountLayout(effectiveEnv) if ((effectiveEnv.RELAYFILE_MOUNT_PATHS_FILE ?? "").trim() !== "") { throw new MountMultiPathUnsupportedError() } @@ -200,12 +198,15 @@ class RelayfileMountProcessInstance implements MountLauncherInstance { } async status(): Promise { + // startRelayfileMount resolves scoped topology before constructing the + // instance. Treat this.localDir as the concrete process root here so a + // direct scoped input does not append the remote path a second time. const status = await readMountedWorkspaceStatus({ localDir: this.localDir, workspaceId: this.input.env.RELAYFILE_WORKSPACE ?? "", - remotePath: this.input.env.RELAYFILE_REMOTE_PATH ?? "/", + remotePath: "/", mode: normalizeMountMode(this.input.env.RELAYFILE_MOUNT_MODE) ?? "poll", - localLayout: normalizeMountLocalLayout(this.input.env.RELAYFILE_MOUNT_LOCAL_LAYOUT), + localLayout: "exact", syncMode: normalizeMountSyncMode(this.input.env.RELAYFILE_MOUNT_SYNC_MODE), relayfileBaseUrl: this.input.env.RELAYFILE_BASE_URL ?? "", relayfileToken: this.input.env.RELAYFILE_TOKEN ?? "", diff --git a/packages/sdk/typescript/src/mount-layout-guard.ts b/packages/sdk/typescript/src/mount-layout-guard.ts deleted file mode 100644 index 89346594..00000000 --- a/packages/sdk/typescript/src/mount-layout-guard.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { MountSessionInputError } from "./setup-errors.js" - -/** Refuse scoped runtime state until all operator surfaces can enumerate it. */ -export function assertExactMountLayout( - env: Record -): void { - if ((env.RELAYFILE_MOUNT_LOCAL_LAYOUT ?? "").trim().toLowerCase() !== "scoped") { - return - } - throw new MountSessionInputError( - "The TypeScript mount launcher does not support RELAYFILE_MOUNT_LOCAL_LAYOUT=scoped until scoped operator surfaces are ready; use RELAYFILE_MOUNT_LOCAL_LAYOUT=exact." - ) -} diff --git a/packages/sdk/typescript/src/setup.test.ts b/packages/sdk/typescript/src/setup.test.ts index 1515173d..074ce3e5 100644 --- a/packages/sdk/typescript/src/setup.test.ts +++ b/packages/sdk/typescript/src/setup.test.ts @@ -1265,7 +1265,7 @@ describe("RelayfileSetup", () => { const setup = new RelayfileSetup() const workspace = await setup.joinWorkspace("ws_123") readyControl.resolve() - await setup.mountWorkspace({ + const handle = await setup.mountWorkspace({ workspace, localDir, remotePath: "/slack/channels/C123/messages", @@ -1288,6 +1288,20 @@ describe("RelayfileSetup", () => { mode: "poll", agentName: "relayfile-mount" }) + const concreteLocalDir = path.join( + localDir, + "slack", + "channels", + "C123", + "messages" + ) + expect(handle.localDir).toBe(concreteLocalDir) + expect(handle.env()).toMatchObject({ + RELAYFILE_REMOTE_PATH: "/slack/channels/C123/messages", + RELAYFILE_LOCAL_DIR: concreteLocalDir, + RELAYFILE_MOUNT_LOCAL_LAYOUT: "exact" + }) + await handle.stop() } finally { await rm(tempRoot, { recursive: true, force: true }) } diff --git a/packages/sdk/typescript/src/setup.ts b/packages/sdk/typescript/src/setup.ts index d6b77a4c..9515c19d 100644 --- a/packages/sdk/typescript/src/setup.ts +++ b/packages/sdk/typescript/src/setup.ts @@ -1166,7 +1166,7 @@ class MountedWorkspaceHandleImpl implements MountedWorkspaceHandle { }) { this.mountSession = input.mountSession this.workspaceId = input.mountSession.workspaceId - this.localDir = input.mountSession.localDir + this.localDir = resolveMountedWorkspaceLocalDir(input.mountSession) this.remotePath = input.mountSession.remotePath this.mode = input.mountSession.mode this.expiresAt = input.mountSession.expiresAt @@ -1181,7 +1181,14 @@ class MountedWorkspaceHandleImpl implements MountedWorkspaceHandle { } env(): Record { - return buildMountedWorkspaceEnv(this.mountSession) + return { + ...buildMountedWorkspaceEnv(this.mountSession), + RELAYFILE_LOCAL_DIR: this.localDir, + // The handle exposes the post-resolution path. Consumers that reuse + // this env must therefore treat it as exact rather than append the + // remote path again. + RELAYFILE_MOUNT_LOCAL_LAYOUT: "exact" + } } async status(): Promise { @@ -1201,7 +1208,9 @@ class MountedWorkspaceHandleImpl implements MountedWorkspaceHandle { workspaceId: this.workspaceId, remotePath: this.remotePath, mode: this.mode, - localLayout: this.mountSession.localLayout, + // this.localDir is already the concrete scoped child exposed by the + // handle, so status reads must not append remotePath a second time. + localLayout: "exact", syncMode: this.mountSession.syncMode, relayfileBaseUrl: this.mountSession.relayfileBaseUrl, relayfileToken: this.mountSession.relayfileToken, @@ -1955,6 +1964,20 @@ function buildMountedWorkspaceEnv( }) } +function resolveMountedWorkspaceLocalDir( + mountSession: MountSessionResult +): string { + if ( + mountSession.localLayout !== "scoped" || + mountSession.remotePath === "/" + ) { + return mountSession.localDir + } + return resolveLocalDir( + `${mountSession.localDir}/${mountSession.remotePath.replace(/^\/+/, "")}` + ) +} + function buildMountLauncherEnv( mountSession: MountSessionResult ): Record { diff --git a/packages/sdk/typescript/src/workspace-mount.test.ts b/packages/sdk/typescript/src/workspace-mount.test.ts index b75f8057..c6c68deb 100644 --- a/packages/sdk/typescript/src/workspace-mount.test.ts +++ b/packages/sdk/typescript/src/workspace-mount.test.ts @@ -1,8 +1,12 @@ import { afterEach, describe, expect, it } from "vitest" +import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" import { ensureRelayfileMount } from "./workspace-mount.js" describe("workspace mount entry point", () => { const previousLayout = process.env.RELAYFILE_MOUNT_LOCAL_LAYOUT + const previousRemotePath = process.env.RELAYFILE_REMOTE_PATH afterEach(() => { if (previousLayout === undefined) { @@ -10,9 +14,14 @@ describe("workspace mount entry point", () => { } else { process.env.RELAYFILE_MOUNT_LOCAL_LAYOUT = previousLayout } + if (previousRemotePath === undefined) { + delete process.env.RELAYFILE_REMOTE_PATH + } else { + process.env.RELAYFILE_REMOTE_PATH = previousRemotePath + } }) - it("refuses inherited scoped layout before resolving or starting the mount binary", async () => { + it("allows inherited scoped layout to reach mount binary resolution", async () => { process.env.RELAYFILE_MOUNT_LOCAL_LAYOUT = "scoped" await expect( @@ -22,9 +31,45 @@ describe("workspace mount entry point", () => { token: "rf_mount_token", binaryPath: "/does/not/exist" }) - ).rejects.toMatchObject({ - name: "MountSessionInputError", - code: "mount_session_input_error" - }) + ).rejects.toThrow("missing relayfile mount binary: /does/not/exist") + }) + + it("returns the concrete child for an inherited scoped native mount", async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "relayfile-workspace-mount-") + ) + const binaryPath = path.join(tempRoot, "relayfile-mount") + const catalogDir = path.join(tempRoot, "catalog") + await writeFile( + binaryPath, + `#!/bin/sh +case " $* " in + *" --once "*) exit 0 ;; +esac +trap 'exit 0' TERM INT +while :; do sleep 1; done +` + ) + await chmod(binaryPath, 0o755) + process.env.RELAYFILE_MOUNT_LOCAL_LAYOUT = "scoped" + process.env.RELAYFILE_REMOTE_PATH = "/github/repos/acme" + + try { + const handle = await ensureRelayfileMount({ + relayfileUrl: "https://relayfile.mount.test", + workspace: "ws_123", + token: "rf_mount_token", + binaryPath, + mountPoint: catalogDir + }) + + expect(handle.mountPoint).toBe( + path.join(catalogDir, "github", "repos", "acme") + ) + await handle.stop() + await expect(stat(catalogDir)).resolves.toBeDefined() + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } }) }) diff --git a/packages/sdk/typescript/src/workspace-mount.ts b/packages/sdk/typescript/src/workspace-mount.ts index 371fa219..ac7ff4b2 100644 --- a/packages/sdk/typescript/src/workspace-mount.ts +++ b/packages/sdk/typescript/src/workspace-mount.ts @@ -17,7 +17,6 @@ import https from 'node:https'; import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; -import { assertExactMountLayout } from './mount-layout-guard.js'; const require = createRequire(import.meta.url); const RELAYFILE_VERSION = String(require('../package.json').version); @@ -348,6 +347,31 @@ function ensureProcessRunning(processRef: ChildProcess): boolean { return processRef.exitCode === null && !processRef.killed; } +function resolveConcreteMountPoint( + mountPoint: string, + mountEnv: NodeJS.ProcessEnv +): string { + if ( + (mountEnv.RELAYFILE_MOUNT_LOCAL_LAYOUT ?? "").trim().toLowerCase() !== + "scoped" + ) { + return mountPoint; + } + const remotePath = (mountEnv.RELAYFILE_REMOTE_PATH ?? "/") + .trim() + .replace(/\\/g, "/"); + const normalizedRemote = path.posix.normalize( + remotePath.startsWith("/") ? remotePath : `/${remotePath}` + ); + if (normalizedRemote === "/") { + return mountPoint; + } + return path.join( + mountPoint, + ...normalizedRemote.split("/").filter(Boolean) + ); +} + async function stopMountProcess(processRef: ChildProcess): Promise { if (processRef.exitCode !== null || !processRef.pid) { return; @@ -369,7 +393,6 @@ async function stopMountProcess(processRef: ChildProcess): Promise { } export async function ensureRelayfileMount(config: MountConfig): Promise { - assertExactMountLayout(process.env); const binaryPath = await ensureRelayfileMountBinary(config.binaryPath); if (!existsSync(binaryPath)) { throw new Error(`missing relayfile mount binary: ${binaryPath}`); @@ -454,10 +477,11 @@ export async function ensureRelayfileMount(config: MountConfig): Promise | undefined; const startedMountProc = mountProc; + const concreteMountPoint = resolveConcreteMountPoint(mountPoint, mountEnv); return { pid: mountProc.pid, - mountPoint, + mountPoint: concreteMountPoint, async stop(): Promise { if (!stopPromise) { // Memoize the in-flight shutdown so concurrent callers all await the