@team-agent/installer 0.5.52 → 0.5.53
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 +18 -2
- package/crates/team-agent/src/cli/emit.rs +17 -0
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/types.rs +11 -0
- package/crates/team-agent/src/diagnose/orphans.rs +24 -3
- package/crates/team-agent/src/kill_audit.rs +67 -0
- package/crates/team-agent/src/leader/lease.rs +324 -57
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
- package/crates/team-agent/src/leader/rediscover.rs +6 -0
- package/crates/team-agent/src/leader/start.rs +144 -23
- package/crates/team-agent/src/leader/tests/idle.rs +3 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
- package/crates/team-agent/src/lifecycle/types.rs +17 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tools.rs +18 -4
- package/crates/team-agent/src/mcp_server/types.rs +3 -0
- package/crates/team-agent/src/mcp_server/wire.rs +30 -7
- package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
- package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
- package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
- package/crates/team-agent/src/provider/adapter.rs +54 -13
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
- package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/session/capture.rs +321 -52
- package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
- package/crates/team-agent/src/provider/session/mod.rs +6 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
- package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
- package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
- package/crates/team-agent/src/provider/session_scan.rs +3 -3
- package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
- package/crates/team-agent/src/provider/tests.rs +1 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
- package/crates/team-agent/src/tmux_backend.rs +29 -2
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +8 -3
|
@@ -258,8 +258,7 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
258
258
|
attempt_payload.clone(),
|
|
259
259
|
)?;
|
|
260
260
|
|
|
261
|
-
let Some(
|
|
262
|
-
else {
|
|
261
|
+
let Some(receiver) = leader_receiver_record(state) else {
|
|
263
262
|
let failed = serde_json::json!({
|
|
264
263
|
"message_id": message_id,
|
|
265
264
|
"result_id": result_id,
|
|
@@ -267,7 +266,7 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
267
266
|
"pane_id": null,
|
|
268
267
|
"primary_error": primary_error,
|
|
269
268
|
"delivered_via": "fallback_pane",
|
|
270
|
-
"reason": "
|
|
269
|
+
"reason": "no_bound_receiver",
|
|
271
270
|
});
|
|
272
271
|
event_log.write("leader_receiver.fallback_pane_failed", failed)?;
|
|
273
272
|
return Ok(DeliveryOutcome {
|
|
@@ -283,7 +282,6 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
283
282
|
};
|
|
284
283
|
|
|
285
284
|
let rendered = render_fallback_pane_message(content, message_id, primary_error);
|
|
286
|
-
let target = Target::Pane(PaneId::new(&pane_id));
|
|
287
285
|
let payload = InjectPayload::Text(rendered);
|
|
288
286
|
// 0.5.5 gate054 cross-team notify boundary: when the leader receiver
|
|
289
287
|
// has a recorded `tmux_socket` (canonical), that socket is a HARD
|
|
@@ -301,25 +299,63 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
301
299
|
// stays pending in the store, coordinator surfaces the pending
|
|
302
300
|
// notification via status/monitor, and no cross-server injection is
|
|
303
301
|
// attempted.
|
|
304
|
-
let
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
None =>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
302
|
+
let socket_bound = leader_tmux_socket(state).is_some();
|
|
303
|
+
let transports: Vec<Box<dyn Transport>> = match leader_tmux_socket(state) {
|
|
304
|
+
Some(socket) => vec![Box::new(crate::transport_factory::tmux_endpoint_transport(
|
|
305
|
+
socket,
|
|
306
|
+
))],
|
|
307
|
+
None => vec![
|
|
308
|
+
Box::new(crate::transport_factory::tmux_workspace_transport(
|
|
309
|
+
workspace,
|
|
310
|
+
)),
|
|
311
|
+
Box::new(crate::transport_factory::tmux_default_transport()),
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
let mut resolved = None;
|
|
315
|
+
let mut refusal = "leader channel unavailable".to_string();
|
|
316
|
+
for transport in transports {
|
|
317
|
+
match super::leader_channel::resolve_live_leader_channel(
|
|
318
|
+
workspace,
|
|
319
|
+
receiver,
|
|
320
|
+
transport.as_ref(),
|
|
321
|
+
) {
|
|
322
|
+
super::leader_channel::LeaderChannelResolution::Live(
|
|
323
|
+
super::leader_channel::LiveLeaderChannel::DirectTmux(channel),
|
|
324
|
+
) => {
|
|
325
|
+
resolved = Some((transport, channel));
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
other => refusal = format!("{other:?}"),
|
|
321
329
|
}
|
|
330
|
+
}
|
|
331
|
+
let Some((transport, channel)) = resolved else {
|
|
332
|
+
event_log.write(
|
|
333
|
+
"leader_receiver.fallback_pane_failed",
|
|
334
|
+
serde_json::json!({
|
|
335
|
+
"message_id": message_id,
|
|
336
|
+
"result_id": result_id,
|
|
337
|
+
"owner_team_id": owner_team_id,
|
|
338
|
+
"pane_id": pane_id,
|
|
339
|
+
"primary_error": primary_error,
|
|
340
|
+
"delivered_via": "fallback_pane",
|
|
341
|
+
"socket_bound": socket_bound,
|
|
342
|
+
"reason": refusal,
|
|
343
|
+
}),
|
|
344
|
+
)?;
|
|
345
|
+
return Ok(DeliveryOutcome {
|
|
346
|
+
ok: false,
|
|
347
|
+
status: DeliveryStatus::Blocked,
|
|
348
|
+
message_status: MessageStatusShadow("blocked".to_string()),
|
|
349
|
+
message_id: Some(message_id.to_string()),
|
|
350
|
+
verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
|
|
351
|
+
stage: None,
|
|
352
|
+
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
353
|
+
channel: Some("rebind_required".to_string()),
|
|
354
|
+
});
|
|
322
355
|
};
|
|
356
|
+
let target = Target::Pane(PaneId::new(channel.pane_id));
|
|
357
|
+
let inject_result: Result<InjectReport, TransportError> =
|
|
358
|
+
transport.inject(&target, &payload, Key::Enter, true);
|
|
323
359
|
|
|
324
360
|
match inject_result {
|
|
325
361
|
Ok(report) => {
|
|
@@ -486,6 +522,17 @@ fn leader_record_field<'a>(state: &'a Value, field: &str) -> Option<&'a Value> {
|
|
|
486
522
|
.or_else(|| receiver_or_owner_field(state, "team_owner", field))
|
|
487
523
|
}
|
|
488
524
|
|
|
525
|
+
fn leader_receiver_record(state: &Value) -> Option<&Value> {
|
|
526
|
+
state.get("leader_receiver").or_else(|| {
|
|
527
|
+
let active = state.get("active_team_key").and_then(Value::as_str)?;
|
|
528
|
+
state
|
|
529
|
+
.get("teams")
|
|
530
|
+
.and_then(Value::as_object)
|
|
531
|
+
.and_then(|teams| teams.get(active))
|
|
532
|
+
.and_then(|team| team.get("leader_receiver"))
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
|
|
489
536
|
fn owner_epoch_i64(state: &Value) -> Option<i64> {
|
|
490
537
|
owner_epoch(state).and_then(|epoch| i64::try_from(epoch).ok())
|
|
491
538
|
}
|
|
@@ -3221,30 +3221,41 @@ fn gate054_status_surfaces_pending_leader_notifications() {
|
|
|
3221
3221
|
|
|
3222
3222
|
#[test]
|
|
3223
3223
|
fn gate054_fallback_pane_grep_guard_no_cross_server_chain_when_socket_recorded() {
|
|
3224
|
-
// Structural grep: the recorded-socket
|
|
3225
|
-
//
|
|
3226
|
-
//
|
|
3227
|
-
//
|
|
3224
|
+
// Structural grep: the recorded-socket transport set must contain only
|
|
3225
|
+
// the endpoint backend, while workspace/default transports remain in the
|
|
3226
|
+
// no-socket arm. Physical injection must consume the pane returned by the
|
|
3227
|
+
// shared resolver rather than rebuilding a target from state.
|
|
3228
3228
|
let src = include_str!("../leader_receiver.rs");
|
|
3229
|
-
let
|
|
3230
|
-
.find("let
|
|
3231
|
-
.expect("fallback
|
|
3232
|
-
let
|
|
3233
|
-
.find("
|
|
3234
|
-
.map(|off|
|
|
3235
|
-
.expect("
|
|
3236
|
-
let
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
let
|
|
3241
|
-
.find("None =>
|
|
3242
|
-
.map(|off|
|
|
3243
|
-
.expect("
|
|
3244
|
-
let
|
|
3229
|
+
let transports_start = src
|
|
3230
|
+
.find("let transports: Vec<Box<dyn Transport>>")
|
|
3231
|
+
.expect("fallback transport set must remain explicit");
|
|
3232
|
+
let transports_end = src[transports_start..]
|
|
3233
|
+
.find("let mut resolved")
|
|
3234
|
+
.map(|off| transports_start + off)
|
|
3235
|
+
.expect("transport selection must precede shared resolution");
|
|
3236
|
+
let transports = &src[transports_start..transports_end];
|
|
3237
|
+
let socket_arm_start = transports
|
|
3238
|
+
.find("Some(socket) =>")
|
|
3239
|
+
.expect("recorded-socket arm");
|
|
3240
|
+
let socket_arm_end = transports[socket_arm_start..]
|
|
3241
|
+
.find("None =>")
|
|
3242
|
+
.map(|off| socket_arm_start + off)
|
|
3243
|
+
.expect("no-socket arm must follow");
|
|
3244
|
+
let socket_arm = &transports[socket_arm_start..socket_arm_end];
|
|
3245
3245
|
assert!(
|
|
3246
|
-
|
|
3247
|
-
&& !
|
|
3248
|
-
|
|
3246
|
+
socket_arm.contains("tmux_endpoint_transport")
|
|
3247
|
+
&& !socket_arm.contains("tmux_workspace_transport")
|
|
3248
|
+
&& !socket_arm.contains("tmux_default_transport"),
|
|
3249
|
+
"0.5.5 gate054: socket-recorded fallback arm must remain endpoint-only; arm={socket_arm}"
|
|
3250
|
+
);
|
|
3251
|
+
let no_socket_arm = &transports[socket_arm_end..];
|
|
3252
|
+
assert!(
|
|
3253
|
+
no_socket_arm.contains("tmux_workspace_transport")
|
|
3254
|
+
&& no_socket_arm.contains("tmux_default_transport"),
|
|
3255
|
+
"0.5.5 gate054: workspace/default transports belong only to the no-socket arm; arm={no_socket_arm}"
|
|
3256
|
+
);
|
|
3257
|
+
assert!(
|
|
3258
|
+
src.contains("let target = Target::Pane(PaneId::new(channel.pane_id));"),
|
|
3259
|
+
"fallback injection must consume the shared resolver's typed channel"
|
|
3249
3260
|
);
|
|
3250
3261
|
}
|
|
@@ -301,14 +301,14 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
301
301
|
native_mcp_config: false,
|
|
302
302
|
writes_global_settings: false,
|
|
303
303
|
},
|
|
304
|
-
// Copilot
|
|
305
|
-
// session
|
|
304
|
+
// Copilot resume 走 --resume <sid>;store fork 在隔离 COPILOT_HOME
|
|
305
|
+
// 复制并重键 session backing 后以 --resume <new sid> 启动。
|
|
306
306
|
// native_mcp_config=true(`--additional-mcp-config` 接 inline JSON 或 @file);
|
|
307
307
|
// writes_global_settings=false(session 走 --session-id 预定 UUID,不污染
|
|
308
308
|
// ~/.copilot/mcp-config.json,help 原文 "augments config for this session")。
|
|
309
309
|
Provider::Copilot => ProviderCaps {
|
|
310
310
|
resume: true,
|
|
311
|
-
fork:
|
|
311
|
+
fork: true,
|
|
312
312
|
native_mcp_config: true,
|
|
313
313
|
writes_global_settings: false,
|
|
314
314
|
},
|
|
@@ -829,10 +829,9 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
829
829
|
argv.push("--fork-session".to_string());
|
|
830
830
|
Ok(argv)
|
|
831
831
|
}
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
// 已在 fork_with_context 入口拦截,line 582),保留作 totality 守护。
|
|
832
|
+
// Context-free adapter calls cannot materialize the Copilot store fork.
|
|
833
|
+
// The lifecycle path uses `fork_plan`, which receives the prepared isolated
|
|
834
|
+
// backing; never degrade this context-free call to a fresh spawn.
|
|
836
835
|
Provider::Copilot => Err(ProviderError::CapabilityUnsupported(
|
|
837
836
|
"copilot CLI 无 fork 旗标,session-store 不支持 branched continuation".to_string(),
|
|
838
837
|
)),
|
|
@@ -858,11 +857,11 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
858
857
|
provider_wire(self.provider)
|
|
859
858
|
)));
|
|
860
859
|
}
|
|
861
|
-
|
|
860
|
+
if session_id.is_none() {
|
|
862
861
|
return Err(ProviderError::ResumeUnavailable(
|
|
863
862
|
"fork requires session_id".to_string(),
|
|
864
863
|
));
|
|
865
|
-
}
|
|
864
|
+
}
|
|
866
865
|
let expected = next_session_token();
|
|
867
866
|
let managed = ctx
|
|
868
867
|
.profile_launch
|
|
@@ -881,11 +880,8 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
881
880
|
managed,
|
|
882
881
|
ctx.effort,
|
|
883
882
|
)?;
|
|
884
|
-
argv.push("--session-id".to_string());
|
|
885
|
-
argv.push(expected.clone());
|
|
886
883
|
argv.push("--resume".to_string());
|
|
887
|
-
argv.push(
|
|
888
|
-
argv.push("--fork-session".to_string());
|
|
884
|
+
argv.push(expected.clone());
|
|
889
885
|
Ok(CommandPlan {
|
|
890
886
|
argv,
|
|
891
887
|
expected_session_id: Some(SessionId::new(expected)),
|
|
@@ -918,6 +914,51 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
918
914
|
argv.push(session_id.as_str().to_string());
|
|
919
915
|
Ok(CommandPlan::argv_only(argv))
|
|
920
916
|
}
|
|
917
|
+
Provider::Copilot => {
|
|
918
|
+
if ctx.auth_mode == AuthMode::CompatibleApi {
|
|
919
|
+
return Err(ProviderError::CapabilityUnsupported(
|
|
920
|
+
"copilot does not support session fork in compatible_api mode".to_string(),
|
|
921
|
+
));
|
|
922
|
+
}
|
|
923
|
+
let profile = ctx.profile_launch.ok_or_else(|| {
|
|
924
|
+
ProviderError::Command(
|
|
925
|
+
"copilot fork requires a prepared provider profile".to_string(),
|
|
926
|
+
)
|
|
927
|
+
})?;
|
|
928
|
+
let expected = profile
|
|
929
|
+
.env_overlay
|
|
930
|
+
.get("TEAM_AGENT_INTERNAL_COPILOT_FORK_SESSION_ID")
|
|
931
|
+
.filter(|value| !value.is_empty())
|
|
932
|
+
.map(SessionId::new)
|
|
933
|
+
.ok_or_else(|| {
|
|
934
|
+
ProviderError::Command(
|
|
935
|
+
"copilot fork session backing was not materialized".to_string(),
|
|
936
|
+
)
|
|
937
|
+
})?;
|
|
938
|
+
let root = profile
|
|
939
|
+
.env_overlay
|
|
940
|
+
.get("COPILOT_HOME")
|
|
941
|
+
.filter(|value| !value.is_empty())
|
|
942
|
+
.map(PathBuf::from)
|
|
943
|
+
.ok_or_else(|| {
|
|
944
|
+
ProviderError::Command("copilot fork root was not materialized".to_string())
|
|
945
|
+
})?;
|
|
946
|
+
let mut argv = copilot_base_command_resume(
|
|
947
|
+
ctx.auth_mode,
|
|
948
|
+
ctx.mcp_config,
|
|
949
|
+
ctx.system_prompt,
|
|
950
|
+
ctx.model,
|
|
951
|
+
ctx.tools,
|
|
952
|
+
);
|
|
953
|
+
argv.push("--resume".to_string());
|
|
954
|
+
argv.push(expected.as_str().to_string());
|
|
955
|
+
Ok(CommandPlan {
|
|
956
|
+
argv,
|
|
957
|
+
expected_session_id: Some(expected),
|
|
958
|
+
provider_projects_root: Some(root),
|
|
959
|
+
managed_mcp_config: profile.managed_mcp_config,
|
|
960
|
+
})
|
|
961
|
+
}
|
|
921
962
|
_ => self
|
|
922
963
|
.fork_with_context(
|
|
923
964
|
session_id,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
|
|
3
|
+
use crate::provider::{ProviderError, SessionId};
|
|
4
|
+
|
|
5
|
+
#[derive(Debug)]
|
|
6
|
+
pub(crate) struct ClaudeForkMaterialization {
|
|
7
|
+
path: PathBuf,
|
|
8
|
+
keep: bool,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
impl ClaudeForkMaterialization {
|
|
12
|
+
pub(crate) fn path(&self) -> &Path {
|
|
13
|
+
&self.path
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
pub(crate) fn keep(&mut self) {
|
|
17
|
+
self.keep = true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
impl Drop for ClaudeForkMaterialization {
|
|
22
|
+
fn drop(&mut self) {
|
|
23
|
+
if !self.keep {
|
|
24
|
+
let _ = std::fs::remove_file(&self.path);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pub(crate) fn materialize_claude_fork(
|
|
30
|
+
source_path: &Path,
|
|
31
|
+
source_session_id: &SessionId,
|
|
32
|
+
target_session_id: &SessionId,
|
|
33
|
+
) -> Result<ClaudeForkMaterialization, ProviderError> {
|
|
34
|
+
let parent = source_path.parent().ok_or_else(|| {
|
|
35
|
+
ProviderError::Io(format!(
|
|
36
|
+
"claude source backing has no parent: {}",
|
|
37
|
+
source_path.display()
|
|
38
|
+
))
|
|
39
|
+
})?;
|
|
40
|
+
let target_path = parent.join(format!("{}.jsonl", target_session_id.as_str()));
|
|
41
|
+
if target_path.exists() {
|
|
42
|
+
return Err(ProviderError::Io(format!(
|
|
43
|
+
"claude fork destination already exists: {}",
|
|
44
|
+
target_path.display()
|
|
45
|
+
)));
|
|
46
|
+
}
|
|
47
|
+
let source = std::fs::read_to_string(source_path)
|
|
48
|
+
.map_err(|error| ProviderError::Io(error.to_string()))?;
|
|
49
|
+
let rewritten = source.replace(source_session_id.as_str(), target_session_id.as_str());
|
|
50
|
+
validate_jsonl(&rewritten)?;
|
|
51
|
+
let temp = parent.join(format!(
|
|
52
|
+
".{}.tmp-{}",
|
|
53
|
+
target_session_id.as_str(),
|
|
54
|
+
std::process::id()
|
|
55
|
+
));
|
|
56
|
+
let result = std::fs::write(&temp, rewritten)
|
|
57
|
+
.and_then(|_| std::fs::rename(&temp, &target_path))
|
|
58
|
+
.map_err(|error| ProviderError::Io(error.to_string()));
|
|
59
|
+
if let Err(error) = result {
|
|
60
|
+
let _ = std::fs::remove_file(&temp);
|
|
61
|
+
return Err(error);
|
|
62
|
+
}
|
|
63
|
+
Ok(ClaudeForkMaterialization {
|
|
64
|
+
path: target_path,
|
|
65
|
+
keep: false,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fn validate_jsonl(text: &str) -> Result<(), ProviderError> {
|
|
70
|
+
let mut rows = 0_usize;
|
|
71
|
+
for (index, line) in text.lines().enumerate() {
|
|
72
|
+
if line.trim().is_empty() {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
serde_json::from_str::<serde_json::Value>(line).map_err(|error| {
|
|
76
|
+
ProviderError::Io(format!(
|
|
77
|
+
"claude fork source has invalid JSONL at line {}: {error}",
|
|
78
|
+
index + 1
|
|
79
|
+
))
|
|
80
|
+
})?;
|
|
81
|
+
rows += 1;
|
|
82
|
+
}
|
|
83
|
+
if rows == 0 {
|
|
84
|
+
return Err(ProviderError::Io(
|
|
85
|
+
"claude fork source backing has no JSONL records".to_string(),
|
|
86
|
+
));
|
|
87
|
+
}
|
|
88
|
+
Ok(())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[cfg(test)]
|
|
92
|
+
mod tests {
|
|
93
|
+
use super::*;
|
|
94
|
+
|
|
95
|
+
#[test]
|
|
96
|
+
fn snapshot_rekeys_every_record_and_preserves_source() {
|
|
97
|
+
let dir = std::env::temp_dir().join(format!("ta-claude-fork-copy-{}", std::process::id()));
|
|
98
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
99
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
100
|
+
let source_id = SessionId::new("11111111-1111-4111-8111-111111111111");
|
|
101
|
+
let target_id = SessionId::new("22222222-2222-4222-8222-222222222222");
|
|
102
|
+
let source_path = dir.join(format!("{}.jsonl", source_id.as_str()));
|
|
103
|
+
let source = format!(
|
|
104
|
+
"{{\"sessionId\":\"{}\"}}\n{{\"payload\":{{\"session_id\":\"{}\"}}}}\n",
|
|
105
|
+
source_id.as_str(),
|
|
106
|
+
source_id.as_str()
|
|
107
|
+
);
|
|
108
|
+
std::fs::write(&source_path, &source).unwrap();
|
|
109
|
+
|
|
110
|
+
let mut materialized =
|
|
111
|
+
materialize_claude_fork(&source_path, &source_id, &target_id).unwrap();
|
|
112
|
+
let target_path = dir.join(format!("{}.jsonl", target_id.as_str()));
|
|
113
|
+
let target = std::fs::read_to_string(&target_path).unwrap();
|
|
114
|
+
assert!(!target.contains(source_id.as_str()));
|
|
115
|
+
assert!(target.contains(target_id.as_str()));
|
|
116
|
+
assert_eq!(std::fs::read_to_string(&source_path).unwrap(), source);
|
|
117
|
+
materialized.keep();
|
|
118
|
+
drop(materialized);
|
|
119
|
+
assert!(target_path.is_file());
|
|
120
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
121
|
+
}
|
|
122
|
+
}
|