@team-agent/installer 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +82 -22
- package/crates/team-agent/src/cli/diagnose.rs +6 -2
- package/crates/team-agent/src/cli/emit.rs +301 -38
- package/crates/team-agent/src/cli/mod.rs +7 -14
- package/crates/team-agent/src/cli/status_port.rs +12 -1
- package/crates/team-agent/src/cli/tests/base.rs +6 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
- package/crates/team-agent/src/cli/tests/leader_watch.rs +5 -3
- package/crates/team-agent/src/cli/tests/main_preserved.rs +28 -2
- package/crates/team-agent/src/cli/tests/peer_allow.rs +19 -0
- package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +72 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +35 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +7 -2
- package/crates/team-agent/src/coordinator/tick.rs +86 -16
- package/crates/team-agent/src/diagnose/comms.rs +10 -2
- package/crates/team-agent/src/leader/lease.rs +98 -19
- package/crates/team-agent/src/leader/rediscover/tests.rs +21 -7
- package/crates/team-agent/src/leader/rediscover.rs +16 -7
- package/crates/team-agent/src/leader/start.rs +25 -12
- package/crates/team-agent/src/leader/tests/byte_findings.rs +11 -2
- package/crates/team-agent/src/leader/tests/lease_claim.rs +248 -7
- package/crates/team-agent/src/lifecycle/launch.rs +116 -100
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -12
- package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +16 -15
- package/crates/team-agent/src/lifecycle/types.rs +1 -1
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -8
- package/crates/team-agent/src/messaging/send.rs +21 -4
- package/crates/team-agent/src/provider/adapter.rs +22 -0
- package/crates/team-agent/src/provider/session/capture.rs +228 -0
- package/crates/team-agent/src/state/identity.rs +42 -12
- package/crates/team-agent/src/state/identity_keys.rs +134 -0
- package/crates/team-agent/src/state/mod.rs +8 -0
- package/crates/team-agent/src/state/owner_gate.rs +11 -1
- package/crates/team-agent/src/state/ownership.rs +556 -0
- package/crates/team-agent/src/state/paths.rs +358 -0
- package/crates/team-agent/src/state/persist.rs +43 -5
- package/crates/team-agent/src/state/projection.rs +13 -5
- package/crates/team-agent/src/tmux_backend.rs +12 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +3 -3
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
//! Stage 2 of identity-boundary unified plan (architect direction 2026-06-23):
|
|
2
|
+
//! `state::ownership` — single read entry point for `team_owner` lookups.
|
|
3
|
+
//!
|
|
4
|
+
//! Today the project has ~30 hand-rolled `state.get("team_owner")` sites and
|
|
5
|
+
//! a separate `team_owner_value` helper in `cli/mod.rs` that re-implements the
|
|
6
|
+
//! teams.<key> → top-level precedence. Stage 2 collapses owner *reads* (not
|
|
7
|
+
//! writes) into a single resolver so:
|
|
8
|
+
//!
|
|
9
|
+
//! 1. The owner gate, restart/start/stop/reset/send/diagnose all see the
|
|
10
|
+
//! SAME owner regardless of whether the caller passed a raw or projected
|
|
11
|
+
//! state — Stage 5 will swap the data source to per-team canonical state
|
|
12
|
+
//! without touching call sites.
|
|
13
|
+
//! 2. Stale `teams.<key>.team_owner` can no longer single-handedly decide
|
|
14
|
+
//! the gate when the canonical truth lives elsewhere (architect §verdict).
|
|
15
|
+
//! 3. Read-only diagnostic CLI / status JSON can ask the repository for the
|
|
16
|
+
//! `OwnershipSource` so legacy duplicates are visible as migration
|
|
17
|
+
//! warnings, not hidden truth.
|
|
18
|
+
//!
|
|
19
|
+
//! What Stage 2 does NOT do (those land in Stage 3):
|
|
20
|
+
//! - Does NOT change writers. `lease.rs` / `start.rs` / `launch.rs` still
|
|
21
|
+
//! write owner at multiple locations; Stage 3 migrates them.
|
|
22
|
+
//! - Does NOT remove duplicate persisted fields. The legacy top-level and
|
|
23
|
+
//! `teams.<key>.team_owner` remain on disk.
|
|
24
|
+
//! - Does NOT add a new file path. Stage 5 introduces
|
|
25
|
+
//! `.team/runtime/<team_key>/state.json` as the canonical source.
|
|
26
|
+
//!
|
|
27
|
+
//! Precedence rule (architect §3 migration precedence; today no canonical
|
|
28
|
+
//! file exists so steps 1+4 are inert):
|
|
29
|
+
//!
|
|
30
|
+
//! 1. canonical `.team/runtime/<team_key>/state.json` (Stage 5 — not wired)
|
|
31
|
+
//! 2. `state.teams.<team_key>.team_owner`
|
|
32
|
+
//! 3. top-level `state.team_owner` IFF `team_state_key(state) == team_key`
|
|
33
|
+
//! 4. snapshot is diagnostic only (not consulted)
|
|
34
|
+
|
|
35
|
+
use serde_json::Value;
|
|
36
|
+
|
|
37
|
+
use crate::state::projection::{read_owner as projection_read_owner, team_state_key};
|
|
38
|
+
|
|
39
|
+
/// Where the repository found the owner record. Diagnostic-only; the owner
|
|
40
|
+
/// gate and other gating callers do not branch on this — they only need the
|
|
41
|
+
/// owner value. CLI status / diagnose use it to surface legacy duplicate
|
|
42
|
+
/// warnings.
|
|
43
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
44
|
+
pub enum OwnershipSource {
|
|
45
|
+
/// Stage 5 canonical path: `.team/runtime/<team_key>/state.json` (not
|
|
46
|
+
/// yet emitted — placeholder for forward compatibility).
|
|
47
|
+
CanonicalPerTeamState,
|
|
48
|
+
/// Legacy projection: `state.teams.<team_key>.team_owner` —
|
|
49
|
+
/// the value the projection layer would have surfaced.
|
|
50
|
+
LegacyTeamsProjection,
|
|
51
|
+
/// Legacy top-level: `state.team_owner`, used when the state's
|
|
52
|
+
/// `team_state_key` matches the requested `team_key`.
|
|
53
|
+
LegacyTopLevel,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Result of a repository read. The `value` is the JSON object the gate /
|
|
57
|
+
/// caller consumes (same shape as `state.get("team_owner")` returned today);
|
|
58
|
+
/// the `source` is diagnostic.
|
|
59
|
+
#[derive(Debug, Clone)]
|
|
60
|
+
pub struct OwnershipRead<'a> {
|
|
61
|
+
pub value: &'a Value,
|
|
62
|
+
pub source: OwnershipSource,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Stage 2 entry point: read the team owner for the given `team_key` from an
|
|
66
|
+
/// in-memory state value, honouring the legacy precedence. Returns `None` if
|
|
67
|
+
/// no source carries a non-empty, valid owner record.
|
|
68
|
+
///
|
|
69
|
+
/// `team_key` may be empty — that path defers to the legacy top-level lookup
|
|
70
|
+
/// only (no teams-projection branch). Callers who already resolved a
|
|
71
|
+
/// `team_key` should pass it; arbitrary projected state can pass `""`.
|
|
72
|
+
///
|
|
73
|
+
/// This deliberately reuses `projection::read_owner` so the pane-id validity
|
|
74
|
+
/// check (`%N` or all-digits) stays in one place. The repository's value-add
|
|
75
|
+
/// is the precedence ordering and the `OwnershipSource` tag.
|
|
76
|
+
pub fn read_owner_for_team<'a>(state: &'a Value, team_key: &str) -> Option<OwnershipRead<'a>> {
|
|
77
|
+
// Step 1 (Stage 5): canonical per-team state. Stage 2 does not consult
|
|
78
|
+
// a separate file yet — owner truth still lives in the in-memory state.
|
|
79
|
+
// Once Stage 5 wires `.team/runtime/<team_key>/state.json` as canonical,
|
|
80
|
+
// a new top-level reader at `state::ownership::read_owner_for_team_at(
|
|
81
|
+
// workspace, team_key)` will check that file first and only fall
|
|
82
|
+
// through to this in-memory resolver for legacy migration.
|
|
83
|
+
|
|
84
|
+
// Step 2: `state.teams.<team_key>.team_owner` (the projection branch).
|
|
85
|
+
if !team_key.is_empty() {
|
|
86
|
+
if let Some(owner) = projection_read_owner(state, Some(team_key)) {
|
|
87
|
+
return Some(OwnershipRead {
|
|
88
|
+
value: owner,
|
|
89
|
+
source: OwnershipSource::LegacyTeamsProjection,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Step 3: top-level `state.team_owner`, only when the state's
|
|
95
|
+
// `team_state_key` agrees with the requested team_key (or when the
|
|
96
|
+
// caller passed an empty key — they're explicitly asking for the
|
|
97
|
+
// arbitrary-projection view, which is the pre-Stage-2 owner_gate
|
|
98
|
+
// shape).
|
|
99
|
+
let top_level_matches = team_key.is_empty() || team_state_key(state) == team_key;
|
|
100
|
+
if top_level_matches {
|
|
101
|
+
if let Some(owner) = projection_read_owner(state, None) {
|
|
102
|
+
return Some(OwnershipRead {
|
|
103
|
+
value: owner,
|
|
104
|
+
source: OwnershipSource::LegacyTopLevel,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
None
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Convenience: just the JSON value, no diagnostic source. Returns `None`
|
|
113
|
+
/// when no owner is found. Stable across Stage 5 — only the lookup
|
|
114
|
+
/// implementation will move.
|
|
115
|
+
pub fn read_owner_value<'a>(state: &'a Value, team_key: &str) -> Option<&'a Value> {
|
|
116
|
+
read_owner_for_team(state, team_key).map(|read| read.value)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Stage 3 (identity-boundary unified plan, architect direction 2026-06-23):
|
|
120
|
+
/// single write entry point for owner mutations. Pre-Stage-3, owner writes
|
|
121
|
+
/// were spread across 13 sites (claim/attach/readopt/managed-leader/quick-
|
|
122
|
+
/// start/identity/send), each independently inserting `team_owner` +
|
|
123
|
+
/// `leader_receiver` + `owner_epoch` into the in-memory state. Stage 3a
|
|
124
|
+
/// collapsed those writers into this API; Stage 3b removed the projection
|
|
125
|
+
/// top-level promote; Stage 3c removed the persist top-level copy-back;
|
|
126
|
+
/// Stage 3d (this final flip) drops the top-level write entirely.
|
|
127
|
+
///
|
|
128
|
+
/// Architect §1.A: the three fields form ONE ownership record. They must
|
|
129
|
+
/// be written together (no half-writes that race in projection/persist).
|
|
130
|
+
///
|
|
131
|
+
/// 3d behaviour: writes ONLY to `state.teams.<team_key>.{team_owner,
|
|
132
|
+
/// leader_receiver, owner_epoch}`. The top-level
|
|
133
|
+
/// `state.{team_owner,leader_receiver,owner_epoch}` is no longer touched
|
|
134
|
+
/// by writers — readers that still consult those fields go through
|
|
135
|
+
/// `state::ownership::read_owner_for_team`, which applies the migration
|
|
136
|
+
/// precedence (teams > top-level only when `team_state_key` matches).
|
|
137
|
+
///
|
|
138
|
+
/// Empty `team_key` is the legacy delivery-view backfill path
|
|
139
|
+
/// (`messaging::send::backfill_leader_binding_for_delivery_view`): an
|
|
140
|
+
/// already-projected state with no team_key context. That path still
|
|
141
|
+
/// writes top-level so the delivery view stays consistent with pre-3a
|
|
142
|
+
/// readers that haven't migrated to the repository. The remaining
|
|
143
|
+
/// top-level write is exactly the one architectural seam Stage 4/5 will
|
|
144
|
+
/// close when CLI/MCP commands carry a team_key explicitly.
|
|
145
|
+
pub fn write_owner(state: &mut Value, team_key: &str, record: OwnershipWrite) {
|
|
146
|
+
if !state.is_object() {
|
|
147
|
+
*state = serde_json::json!({});
|
|
148
|
+
}
|
|
149
|
+
// Empty team_key: legacy in-memory delivery-view backfill. Preserve
|
|
150
|
+
// top-level dual-write semantics for this narrow caller.
|
|
151
|
+
if team_key.is_empty() {
|
|
152
|
+
if let Some(root) = state.as_object_mut() {
|
|
153
|
+
if let Some(receiver) = record.leader_receiver.as_ref() {
|
|
154
|
+
root.insert("leader_receiver".to_string(), receiver.clone());
|
|
155
|
+
}
|
|
156
|
+
if let Some(owner) = record.team_owner.as_ref() {
|
|
157
|
+
root.insert("team_owner".to_string(), owner.clone());
|
|
158
|
+
}
|
|
159
|
+
if let Some(epoch) = record.owner_epoch {
|
|
160
|
+
root.insert("owner_epoch".to_string(), serde_json::json!(epoch));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// Canonical teams.<key> write. Stage 5 will move this to a per-team
|
|
166
|
+
// state file; the call sites do not change.
|
|
167
|
+
let teams = state
|
|
168
|
+
.as_object_mut()
|
|
169
|
+
.and_then(|root| {
|
|
170
|
+
root.entry("teams")
|
|
171
|
+
.or_insert_with(|| serde_json::json!({}))
|
|
172
|
+
.as_object_mut()
|
|
173
|
+
});
|
|
174
|
+
let Some(teams) = teams else { return };
|
|
175
|
+
let entry = teams
|
|
176
|
+
.entry(team_key.to_string())
|
|
177
|
+
.or_insert_with(|| serde_json::json!({}));
|
|
178
|
+
let Some(entry_obj) = entry.as_object_mut() else {
|
|
179
|
+
return;
|
|
180
|
+
};
|
|
181
|
+
if let Some(receiver) = record.leader_receiver.as_ref() {
|
|
182
|
+
entry_obj.insert("leader_receiver".to_string(), receiver.clone());
|
|
183
|
+
}
|
|
184
|
+
if let Some(owner) = record.team_owner.as_ref() {
|
|
185
|
+
entry_obj.insert("team_owner".to_string(), owner.clone());
|
|
186
|
+
}
|
|
187
|
+
if let Some(epoch) = record.owner_epoch {
|
|
188
|
+
entry_obj.insert("owner_epoch".to_string(), serde_json::json!(epoch));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Stage 3 top-level cleanup (architect direction 2026-06-24,
|
|
193
|
+
/// .team/artifacts/stage3-toplevel-cleanup-fix.md): strip the legacy
|
|
194
|
+
/// top-level `team_owner / leader_receiver / owner_epoch` fields from a
|
|
195
|
+
/// state root after a canonical `teams.<key>` ownership write has been
|
|
196
|
+
/// published. Required because `save_claim_team_scoped_state` builds the
|
|
197
|
+
/// to-be-persisted root from `value_object(state)` which still carries
|
|
198
|
+
/// stale top-level owner copies from older saves (or from a projected
|
|
199
|
+
/// state that promoted legacy values into the top-level view). Without
|
|
200
|
+
/// this strip, every claim-leader save leaves a stale duplicate at the
|
|
201
|
+
/// root and the dual-source bug returns.
|
|
202
|
+
///
|
|
203
|
+
/// Used only at owner-mutation save boundaries; do NOT call from a
|
|
204
|
+
/// load-time path — legacy single-team states may still have only
|
|
205
|
+
/// top-level owner and no canonical `teams.<key>` owner, and a blind load
|
|
206
|
+
/// strip would lose ownership before migration.
|
|
207
|
+
pub fn strip_top_level_ownership(root: &mut serde_json::Map<String, Value>) {
|
|
208
|
+
for key in ["team_owner", "leader_receiver", "owner_epoch"] {
|
|
209
|
+
root.remove(key);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// Stage 3 save-output canonical-aware strip (architect direction
|
|
214
|
+
/// 2026-06-24, .team/artifacts/stage3-save-strip-fix.md): conditional
|
|
215
|
+
/// version of `strip_top_level_ownership` used by `state::persist`'s
|
|
216
|
+
/// persistence boundary. Only strips when a canonical
|
|
217
|
+
/// `teams.<active_team_key>.team_owner` record exists; legacy single-team
|
|
218
|
+
/// states whose only owner copy still lives at the root are left
|
|
219
|
+
/// untouched so the `read_owner_for_team` legacy fallback can keep
|
|
220
|
+
/// serving them through the migration window.
|
|
221
|
+
///
|
|
222
|
+
/// This is the SINGLE call point for owner cleanup at save time. Every
|
|
223
|
+
/// `save_runtime_state*` path funnels through here, so claim / restart /
|
|
224
|
+
/// shutdown / start-agent / stop-agent / coordinator tick / promote
|
|
225
|
+
/// sibling are all uniformly cleaned without per-call-site patches (the
|
|
226
|
+
/// scatter-patching approach S3-OWNER-002 showed was incomplete).
|
|
227
|
+
pub fn strip_top_level_ownership_if_canonical_present(state: &mut Value) {
|
|
228
|
+
let canonical_key = active_team_key_for_strip(state);
|
|
229
|
+
if canonical_key.is_empty() {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
let teams_entry_has_owner = state
|
|
233
|
+
.get("teams")
|
|
234
|
+
.and_then(Value::as_object)
|
|
235
|
+
.and_then(|teams| teams.get(&canonical_key))
|
|
236
|
+
.and_then(Value::as_object)
|
|
237
|
+
.is_some_and(|entry| entry.contains_key("team_owner"));
|
|
238
|
+
if !teams_entry_has_owner {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if let Some(root) = state.as_object_mut() {
|
|
242
|
+
strip_top_level_ownership(root);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/// Resolve the canonical team_key for the strip predicate. Architect's
|
|
247
|
+
/// §Modification block keys the strip on `active_team_key`, falling back
|
|
248
|
+
/// to `team_state_key(state)` for the legacy unscoped flow — same
|
|
249
|
+
/// precedence as `canonical_owner_write_key` in `leader/lease.rs` so the
|
|
250
|
+
/// write/strip pair stay aligned on the same key.
|
|
251
|
+
fn active_team_key_for_strip(state: &Value) -> String {
|
|
252
|
+
if let Some(active) = state
|
|
253
|
+
.get("active_team_key")
|
|
254
|
+
.and_then(Value::as_str)
|
|
255
|
+
.filter(|key| !key.is_empty())
|
|
256
|
+
{
|
|
257
|
+
return active.to_string();
|
|
258
|
+
}
|
|
259
|
+
crate::state::projection::team_state_key(state)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/// The ownership write payload. All fields optional so callers can update a
|
|
263
|
+
/// subset — receiver-only attach paths don't need to re-emit team_owner.
|
|
264
|
+
#[derive(Debug, Clone, Default)]
|
|
265
|
+
pub struct OwnershipWrite {
|
|
266
|
+
pub team_owner: Option<Value>,
|
|
267
|
+
pub leader_receiver: Option<Value>,
|
|
268
|
+
pub owner_epoch: Option<u64>,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
impl OwnershipWrite {
|
|
272
|
+
pub fn new() -> Self {
|
|
273
|
+
Self::default()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pub fn with_team_owner(mut self, owner: Value) -> Self {
|
|
277
|
+
self.team_owner = Some(owner);
|
|
278
|
+
self
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
pub fn with_leader_receiver(mut self, receiver: Value) -> Self {
|
|
282
|
+
self.leader_receiver = Some(receiver);
|
|
283
|
+
self
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
pub fn with_owner_epoch(mut self, epoch: u64) -> Self {
|
|
287
|
+
self.owner_epoch = Some(epoch);
|
|
288
|
+
self
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#[cfg(test)]
|
|
293
|
+
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
294
|
+
mod tests {
|
|
295
|
+
use super::*;
|
|
296
|
+
use serde_json::json;
|
|
297
|
+
|
|
298
|
+
fn owner_with_pane(pane: &str) -> Value {
|
|
299
|
+
json!({
|
|
300
|
+
"pane_id": pane,
|
|
301
|
+
"leader_session_uuid": format!("uuid-for-{pane}"),
|
|
302
|
+
"owner_epoch": 1,
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#[test]
|
|
307
|
+
fn returns_none_when_no_owner_anywhere() {
|
|
308
|
+
let state = json!({"active_team_key": "alpha"});
|
|
309
|
+
assert!(read_owner_for_team(&state, "alpha").is_none());
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#[test]
|
|
313
|
+
fn reads_top_level_when_team_state_key_matches() {
|
|
314
|
+
let state = json!({
|
|
315
|
+
"team_key": "alpha",
|
|
316
|
+
"team_owner": owner_with_pane("%top"),
|
|
317
|
+
});
|
|
318
|
+
let read = read_owner_for_team(&state, "alpha").expect("owner found");
|
|
319
|
+
assert_eq!(read.source, OwnershipSource::LegacyTopLevel);
|
|
320
|
+
assert_eq!(read.value["pane_id"], json!("%top"));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#[test]
|
|
324
|
+
fn refuses_top_level_when_team_state_key_disagrees() {
|
|
325
|
+
// Architect §verdict point 2: stale top-level owner from a different
|
|
326
|
+
// team must not be returned for `read_owner_for_team(state, "beta")`.
|
|
327
|
+
let state = json!({
|
|
328
|
+
"team_key": "alpha",
|
|
329
|
+
"team_owner": owner_with_pane("%alpha"),
|
|
330
|
+
});
|
|
331
|
+
assert!(read_owner_for_team(&state, "beta").is_none());
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#[test]
|
|
335
|
+
fn teams_projection_wins_over_top_level_when_both_present() {
|
|
336
|
+
// Architect §3 migration precedence step 2 > step 3: legacy projection
|
|
337
|
+
// outranks legacy top-level. This is the shape the existing
|
|
338
|
+
// `cli::team_owner_value` helper already encoded; lifting it into
|
|
339
|
+
// the repository preserves the behaviour for all callers.
|
|
340
|
+
let state = json!({
|
|
341
|
+
"team_key": "alpha",
|
|
342
|
+
"team_owner": owner_with_pane("%top"),
|
|
343
|
+
"teams": {"alpha": {"team_owner": owner_with_pane("%teams")}},
|
|
344
|
+
});
|
|
345
|
+
let read = read_owner_for_team(&state, "alpha").expect("owner found");
|
|
346
|
+
assert_eq!(read.source, OwnershipSource::LegacyTeamsProjection);
|
|
347
|
+
assert_eq!(read.value["pane_id"], json!("%teams"));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
#[test]
|
|
351
|
+
fn empty_team_key_only_reads_top_level() {
|
|
352
|
+
// The owner-gate today calls `read_owner(state, None)` — that pre-Stage-2
|
|
353
|
+
// shape maps to `read_owner_for_team(state, "")`. The empty-key path
|
|
354
|
+
// must NOT branch into teams.<key> (the gate caller is feeding an
|
|
355
|
+
// already-projected state in the legacy flow).
|
|
356
|
+
let state = json!({
|
|
357
|
+
"team_owner": owner_with_pane("%top"),
|
|
358
|
+
"teams": {"alpha": {"team_owner": owner_with_pane("%teams")}},
|
|
359
|
+
});
|
|
360
|
+
let read = read_owner_for_team(&state, "").expect("owner found");
|
|
361
|
+
assert_eq!(read.source, OwnershipSource::LegacyTopLevel);
|
|
362
|
+
assert_eq!(read.value["pane_id"], json!("%top"));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#[test]
|
|
366
|
+
fn rejects_invalid_pane_id_consistent_with_projection_read_owner() {
|
|
367
|
+
// The pane-id validity check (must start with `%` or be all-digits)
|
|
368
|
+
// is preserved — this is the legacy `valid_owner_pane_id` behaviour
|
|
369
|
+
// we inherit from projection::read_owner.
|
|
370
|
+
let state = json!({
|
|
371
|
+
"team_key": "alpha",
|
|
372
|
+
"team_owner": json!({"pane_id": "not-a-pane"}),
|
|
373
|
+
});
|
|
374
|
+
assert!(read_owner_for_team(&state, "alpha").is_none());
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#[test]
|
|
378
|
+
fn read_owner_value_returns_just_the_value() {
|
|
379
|
+
let state = json!({
|
|
380
|
+
"team_key": "alpha",
|
|
381
|
+
"team_owner": owner_with_pane("%top"),
|
|
382
|
+
});
|
|
383
|
+
let value = read_owner_value(&state, "alpha").expect("owner found");
|
|
384
|
+
assert_eq!(value["pane_id"], json!("%top"));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ───────────── Stage 3a: write_owner API tests ─────────────
|
|
388
|
+
|
|
389
|
+
#[test]
|
|
390
|
+
fn write_owner_writes_only_teams_projection_for_named_team() {
|
|
391
|
+
// 3d contract: a named team_key writes ONLY teams.<key>. Top-level
|
|
392
|
+
// is no longer touched by canonical writers — readers go through
|
|
393
|
+
// the repository which applies migration precedence.
|
|
394
|
+
let mut state = json!({});
|
|
395
|
+
let record = OwnershipWrite::new()
|
|
396
|
+
.with_team_owner(owner_with_pane("%new"))
|
|
397
|
+
.with_owner_epoch(7)
|
|
398
|
+
.with_leader_receiver(json!({"pane_id": "%new", "mode": "direct_tmux"}));
|
|
399
|
+
write_owner(&mut state, "alpha", record);
|
|
400
|
+
// Top-level: NOT written.
|
|
401
|
+
assert!(state.get("team_owner").is_none());
|
|
402
|
+
assert!(state.get("owner_epoch").is_none());
|
|
403
|
+
assert!(state.get("leader_receiver").is_none());
|
|
404
|
+
// Canonical teams.<key>: written.
|
|
405
|
+
assert_eq!(state["teams"]["alpha"]["team_owner"]["pane_id"], json!("%new"));
|
|
406
|
+
assert_eq!(state["teams"]["alpha"]["owner_epoch"], json!(7));
|
|
407
|
+
assert_eq!(
|
|
408
|
+
state["teams"]["alpha"]["leader_receiver"]["pane_id"],
|
|
409
|
+
json!("%new")
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#[test]
|
|
414
|
+
fn write_owner_supports_partial_updates() {
|
|
415
|
+
// Receiver-only attach path doesn't need to re-emit team_owner.
|
|
416
|
+
let mut state = json!({
|
|
417
|
+
"teams": {"alpha": {"team_owner": owner_with_pane("%existing")}},
|
|
418
|
+
});
|
|
419
|
+
let record = OwnershipWrite::new()
|
|
420
|
+
.with_leader_receiver(json!({"pane_id": "%existing", "mode": "direct_tmux"}));
|
|
421
|
+
write_owner(&mut state, "alpha", record);
|
|
422
|
+
// Existing team_owner untouched.
|
|
423
|
+
assert_eq!(
|
|
424
|
+
state["teams"]["alpha"]["team_owner"]["pane_id"],
|
|
425
|
+
json!("%existing")
|
|
426
|
+
);
|
|
427
|
+
// Receiver written only to teams.<key>.
|
|
428
|
+
assert!(state.get("leader_receiver").is_none());
|
|
429
|
+
assert_eq!(
|
|
430
|
+
state["teams"]["alpha"]["leader_receiver"]["pane_id"],
|
|
431
|
+
json!("%existing")
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#[test]
|
|
436
|
+
fn write_owner_with_empty_team_key_only_writes_top_level() {
|
|
437
|
+
// The owner-gate-style attach with no team scope: still preserve the
|
|
438
|
+
// legacy single-source top-level write. Stage 5 will refuse this
|
|
439
|
+
// path; for now it remains compatible.
|
|
440
|
+
let mut state = json!({});
|
|
441
|
+
let record = OwnershipWrite::new().with_team_owner(owner_with_pane("%top"));
|
|
442
|
+
write_owner(&mut state, "", record);
|
|
443
|
+
assert_eq!(state["team_owner"]["pane_id"], json!("%top"));
|
|
444
|
+
assert!(
|
|
445
|
+
state.get("teams").is_none_or(|teams| teams
|
|
446
|
+
.as_object()
|
|
447
|
+
.is_some_and(|map| map.is_empty())),
|
|
448
|
+
"empty team_key must NOT touch teams.<key>"
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#[test]
|
|
453
|
+
fn strip_if_canonical_present_strips_when_teams_entry_has_owner() {
|
|
454
|
+
// Canonical-aware strip: both root AND teams.<active> have owner →
|
|
455
|
+
// root gets stripped, canonical preserved.
|
|
456
|
+
let mut state = json!({
|
|
457
|
+
"active_team_key": "alpha",
|
|
458
|
+
"team_owner": owner_with_pane("%stale"),
|
|
459
|
+
"leader_receiver": {"pane_id": "%stale"},
|
|
460
|
+
"owner_epoch": 1,
|
|
461
|
+
"teams": {
|
|
462
|
+
"alpha": {
|
|
463
|
+
"team_owner": owner_with_pane("%canonical"),
|
|
464
|
+
"owner_epoch": 2,
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
strip_top_level_ownership_if_canonical_present(&mut state);
|
|
469
|
+
assert!(state.get("team_owner").is_none());
|
|
470
|
+
assert!(state.get("leader_receiver").is_none());
|
|
471
|
+
assert!(state.get("owner_epoch").is_none());
|
|
472
|
+
assert_eq!(
|
|
473
|
+
state["teams"]["alpha"]["team_owner"]["pane_id"],
|
|
474
|
+
json!("%canonical")
|
|
475
|
+
);
|
|
476
|
+
assert_eq!(state["teams"]["alpha"]["owner_epoch"], json!(2));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#[test]
|
|
480
|
+
fn strip_if_canonical_present_preserves_legacy_only_state() {
|
|
481
|
+
// Legacy-only state: root owner exists but no canonical
|
|
482
|
+
// teams.<active> owner. The strip MUST NOT remove root — that
|
|
483
|
+
// would lose ownership before the migration window completes.
|
|
484
|
+
// `read_owner_for_team` fallback still serves these readers.
|
|
485
|
+
let mut state = json!({
|
|
486
|
+
"active_team_key": "alpha",
|
|
487
|
+
"team_owner": owner_with_pane("%legacy"),
|
|
488
|
+
"leader_receiver": {"pane_id": "%legacy"},
|
|
489
|
+
"owner_epoch": 1,
|
|
490
|
+
});
|
|
491
|
+
strip_top_level_ownership_if_canonical_present(&mut state);
|
|
492
|
+
assert_eq!(state["team_owner"]["pane_id"], json!("%legacy"));
|
|
493
|
+
assert_eq!(state["leader_receiver"]["pane_id"], json!("%legacy"));
|
|
494
|
+
assert_eq!(state["owner_epoch"], json!(1));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
#[test]
|
|
498
|
+
fn strip_if_canonical_present_falls_back_to_team_state_key() {
|
|
499
|
+
// No active_team_key → fall back to team_state_key (here:
|
|
500
|
+
// session_name "team-agent-x"). If that bucket has canonical
|
|
501
|
+
// owner, strip root.
|
|
502
|
+
let mut state = json!({
|
|
503
|
+
"session_name": "team-agent-x",
|
|
504
|
+
"team_owner": owner_with_pane("%stale"),
|
|
505
|
+
"teams": {
|
|
506
|
+
"team-agent-x": {"team_owner": owner_with_pane("%canonical")},
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
strip_top_level_ownership_if_canonical_present(&mut state);
|
|
510
|
+
assert!(state.get("team_owner").is_none());
|
|
511
|
+
assert_eq!(
|
|
512
|
+
state["teams"]["team-agent-x"]["team_owner"]["pane_id"],
|
|
513
|
+
json!("%canonical")
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#[test]
|
|
518
|
+
fn strip_top_level_ownership_removes_legacy_fields_only() {
|
|
519
|
+
// Stage 3 top-level cleanup: stripping legacy root owner fields
|
|
520
|
+
// must not touch unrelated state keys or the canonical teams.<key>
|
|
521
|
+
// ownership record.
|
|
522
|
+
let mut state = json!({
|
|
523
|
+
"session_name": "team-agent-x",
|
|
524
|
+
"team_owner": owner_with_pane("%stale"),
|
|
525
|
+
"leader_receiver": {"pane_id": "%stale", "mode": "direct_tmux"},
|
|
526
|
+
"owner_epoch": 1,
|
|
527
|
+
"teams": {"alpha": {"team_owner": owner_with_pane("%canonical"), "owner_epoch": 2}},
|
|
528
|
+
});
|
|
529
|
+
let root = state.as_object_mut().expect("root object");
|
|
530
|
+
strip_top_level_ownership(root);
|
|
531
|
+
assert!(root.get("team_owner").is_none());
|
|
532
|
+
assert!(root.get("leader_receiver").is_none());
|
|
533
|
+
assert!(root.get("owner_epoch").is_none());
|
|
534
|
+
// Unrelated fields preserved.
|
|
535
|
+
assert_eq!(root.get("session_name"), Some(&json!("team-agent-x")));
|
|
536
|
+
// Canonical teams.<key> untouched.
|
|
537
|
+
assert_eq!(
|
|
538
|
+
root["teams"]["alpha"]["team_owner"]["pane_id"],
|
|
539
|
+
json!("%canonical")
|
|
540
|
+
);
|
|
541
|
+
assert_eq!(root["teams"]["alpha"]["owner_epoch"], json!(2));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
#[test]
|
|
545
|
+
fn write_owner_then_read_round_trip() {
|
|
546
|
+
// 3a write + Stage 2 read agree: after writing for "alpha", reading
|
|
547
|
+
// for "alpha" returns the LegacyTeamsProjection branch (teams wins
|
|
548
|
+
// over top-level per the precedence rule).
|
|
549
|
+
let mut state = json!({"team_key": "alpha"});
|
|
550
|
+
let record = OwnershipWrite::new().with_team_owner(owner_with_pane("%w1"));
|
|
551
|
+
write_owner(&mut state, "alpha", record);
|
|
552
|
+
let read = read_owner_for_team(&state, "alpha").expect("owner found");
|
|
553
|
+
assert_eq!(read.source, OwnershipSource::LegacyTeamsProjection);
|
|
554
|
+
assert_eq!(read.value["pane_id"], json!("%w1"));
|
|
555
|
+
}
|
|
556
|
+
}
|