@team-agent/installer 0.5.52 → 0.5.54
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 +111 -3
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/send/persist.rs +1 -0
- package/crates/team-agent/src/cli/send/presentation.rs +1 -0
- package/crates/team-agent/src/cli/send.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +12 -0
- package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
- package/crates/team-agent/src/db/message_store.rs +31 -2
- package/crates/team-agent/src/db/migration.rs +7 -6
- package/crates/team-agent/src/db/schema.rs +18 -5
- 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/tests/phase_golden.rs +1 -0
- 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/normalize.rs +8 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tools.rs +89 -4
- package/crates/team-agent/src/mcp_server/types.rs +7 -0
- package/crates/team-agent/src/mcp_server/wire.rs +65 -4
- package/crates/team-agent/src/messaging/delivery.rs +2 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
- package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/persist.rs +68 -2
- package/crates/team-agent/src/messaging/presentation.rs +307 -0
- package/crates/team-agent/src/messaging/results.rs +130 -3
- package/crates/team-agent/src/messaging/selftest.rs +1 -0
- package/crates/team-agent/src/messaging/send.rs +54 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
- package/crates/team-agent/src/messaging/types.rs +2 -0
- package/crates/team-agent/src/messaging/watchers.rs +1 -0
- 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 +395 -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/tests.rs +112 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
- 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/schemas/result-envelope.schema.json +10 -0
- package/skills/team-agent/SKILL.md +11 -3
|
@@ -446,7 +446,10 @@ fn message_not_silently_stuck_accepted_when_coordinator_dead() {
|
|
|
446
446
|
assert!(out.ok, "durable persistence is the send success boundary");
|
|
447
447
|
assert_eq!(out.status, DeliveryStatus::Blocked);
|
|
448
448
|
assert_eq!(out.message_status.0, "queued_coordinator_unavailable");
|
|
449
|
-
assert!(out
|
|
449
|
+
assert!(out
|
|
450
|
+
.message_id
|
|
451
|
+
.as_deref()
|
|
452
|
+
.is_some_and(|id| id.starts_with("msg_")));
|
|
450
453
|
assert_eq!(out.reason, Some(DeliveryRefusal::CoordinatorUnavailable));
|
|
451
454
|
assert!(
|
|
452
455
|
out.verification
|
|
@@ -2290,6 +2293,53 @@ fn u1_multi_team_send_does_not_backfill_top_level_leader_binding() {
|
|
|
2290
2293
|
assert_eq!(owner_team_id.as_deref(), Some("team-b"));
|
|
2291
2294
|
}
|
|
2292
2295
|
|
|
2296
|
+
#[test]
|
|
2297
|
+
fn casefile_leader_send_is_durable_without_entering_leader_funnel() {
|
|
2298
|
+
let ws = tmp_ws("casefile-send");
|
|
2299
|
+
crate::state::persist::save_runtime_state(
|
|
2300
|
+
&ws,
|
|
2301
|
+
&serde_json::json!({
|
|
2302
|
+
"session_name": "team-a",
|
|
2303
|
+
"active_team_key": "team-a",
|
|
2304
|
+
"agents": {}
|
|
2305
|
+
}),
|
|
2306
|
+
)
|
|
2307
|
+
.unwrap();
|
|
2308
|
+
let opts = SendOptions {
|
|
2309
|
+
team: Some(TeamKey::new("team-a")),
|
|
2310
|
+
requires_ack: false,
|
|
2311
|
+
presentation: crate::messaging::presentation::PresentationRequest {
|
|
2312
|
+
sink: crate::messaging::presentation::PresentationSink::Casefile,
|
|
2313
|
+
class: crate::messaging::presentation::PresentationClass::Progress,
|
|
2314
|
+
case_id: Some("case-1".to_string()),
|
|
2315
|
+
},
|
|
2316
|
+
..SendOptions::default()
|
|
2317
|
+
};
|
|
2318
|
+
let out = send_message(
|
|
2319
|
+
&ws,
|
|
2320
|
+
&MessageTarget::Single("leader".to_string()),
|
|
2321
|
+
"internal progress",
|
|
2322
|
+
&opts,
|
|
2323
|
+
)
|
|
2324
|
+
.unwrap();
|
|
2325
|
+
assert_eq!(out.status, DeliveryStatus::StoredOnly);
|
|
2326
|
+
assert_eq!(out.message_status.0, "stored_only");
|
|
2327
|
+
let store = store_for(&ws);
|
|
2328
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
2329
|
+
let (status, presentation): (String, String) = conn
|
|
2330
|
+
.query_row(
|
|
2331
|
+
"select status, presentation from messages where message_id = ?1",
|
|
2332
|
+
[out.message_id.as_deref().unwrap()],
|
|
2333
|
+
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
2334
|
+
)
|
|
2335
|
+
.unwrap();
|
|
2336
|
+
assert_eq!(status, "stored_only");
|
|
2337
|
+
assert_eq!(
|
|
2338
|
+
serde_json::from_str::<serde_json::Value>(&presentation).unwrap()["effective_sink"],
|
|
2339
|
+
"casefile"
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2293
2343
|
// ════════════════════════════════════════════════════════════════════════
|
|
2294
2344
|
// GROUP V — retry_result_deliveries: re-route notify_failed watchers with
|
|
2295
2345
|
// dedupe_reason rebind_retry. result_delivery.py:19-35.
|
|
@@ -3221,30 +3271,41 @@ fn gate054_status_surfaces_pending_leader_notifications() {
|
|
|
3221
3271
|
|
|
3222
3272
|
#[test]
|
|
3223
3273
|
fn gate054_fallback_pane_grep_guard_no_cross_server_chain_when_socket_recorded() {
|
|
3224
|
-
// Structural grep: the recorded-socket
|
|
3225
|
-
//
|
|
3226
|
-
//
|
|
3227
|
-
//
|
|
3274
|
+
// Structural grep: the recorded-socket transport set must contain only
|
|
3275
|
+
// the endpoint backend, while workspace/default transports remain in the
|
|
3276
|
+
// no-socket arm. Physical injection must consume the pane returned by the
|
|
3277
|
+
// shared resolver rather than rebuilding a target from state.
|
|
3228
3278
|
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
|
|
3279
|
+
let transports_start = src
|
|
3280
|
+
.find("let transports: Vec<Box<dyn Transport>>")
|
|
3281
|
+
.expect("fallback transport set must remain explicit");
|
|
3282
|
+
let transports_end = src[transports_start..]
|
|
3283
|
+
.find("let mut resolved")
|
|
3284
|
+
.map(|off| transports_start + off)
|
|
3285
|
+
.expect("transport selection must precede shared resolution");
|
|
3286
|
+
let transports = &src[transports_start..transports_end];
|
|
3287
|
+
let socket_arm_start = transports
|
|
3288
|
+
.find("Some(socket) =>")
|
|
3289
|
+
.expect("recorded-socket arm");
|
|
3290
|
+
let socket_arm_end = transports[socket_arm_start..]
|
|
3291
|
+
.find("None =>")
|
|
3292
|
+
.map(|off| socket_arm_start + off)
|
|
3293
|
+
.expect("no-socket arm must follow");
|
|
3294
|
+
let socket_arm = &transports[socket_arm_start..socket_arm_end];
|
|
3295
|
+
assert!(
|
|
3296
|
+
socket_arm.contains("tmux_endpoint_transport")
|
|
3297
|
+
&& !socket_arm.contains("tmux_workspace_transport")
|
|
3298
|
+
&& !socket_arm.contains("tmux_default_transport"),
|
|
3299
|
+
"0.5.5 gate054: socket-recorded fallback arm must remain endpoint-only; arm={socket_arm}"
|
|
3300
|
+
);
|
|
3301
|
+
let no_socket_arm = &transports[socket_arm_end..];
|
|
3302
|
+
assert!(
|
|
3303
|
+
no_socket_arm.contains("tmux_workspace_transport")
|
|
3304
|
+
&& no_socket_arm.contains("tmux_default_transport"),
|
|
3305
|
+
"0.5.5 gate054: workspace/default transports belong only to the no-socket arm; arm={no_socket_arm}"
|
|
3306
|
+
);
|
|
3245
3307
|
assert!(
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
"0.5.5 gate054: socket-recorded fallback arm must not compose workspace/default backends; arm={some_arm}"
|
|
3308
|
+
src.contains("let target = Target::Pane(PaneId::new(channel.pane_id));"),
|
|
3309
|
+
"fallback injection must consume the shared resolver's typed channel"
|
|
3249
3310
|
);
|
|
3250
3311
|
}
|
|
@@ -20,6 +20,8 @@ use super::helpers::MessageStatusShadow;
|
|
|
20
20
|
#[serde(rename_all = "snake_case")]
|
|
21
21
|
pub enum DeliveryStatus {
|
|
22
22
|
Delivered,
|
|
23
|
+
/// Durable presentation obligation intentionally did not enter physical injection.
|
|
24
|
+
StoredOnly,
|
|
23
25
|
Failed,
|
|
24
26
|
/// busy → 延后不丢 (card §131:不 mark failed,留队列)。
|
|
25
27
|
Queued,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
|
|
3
|
+
use rusqlite::{params, Connection};
|
|
4
|
+
|
|
5
|
+
use crate::provider::{next_session_token, ProviderError, SessionId};
|
|
6
|
+
|
|
7
|
+
const SESSION_TABLES: [&str; 6] = [
|
|
8
|
+
"turns",
|
|
9
|
+
"checkpoints",
|
|
10
|
+
"session_files",
|
|
11
|
+
"session_refs",
|
|
12
|
+
"forge_trajectory_events",
|
|
13
|
+
"search_index",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
#[derive(Debug, Clone)]
|
|
17
|
+
pub(crate) struct CopilotForkMaterialization {
|
|
18
|
+
session_id: SessionId,
|
|
19
|
+
home: PathBuf,
|
|
20
|
+
keep: bool,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
impl CopilotForkMaterialization {
|
|
24
|
+
pub(crate) fn session_id(&self) -> &SessionId {
|
|
25
|
+
&self.session_id
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
pub(crate) fn home(&self) -> &Path {
|
|
29
|
+
&self.home
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub(crate) fn keep(&mut self) {
|
|
33
|
+
self.keep = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
impl Drop for CopilotForkMaterialization {
|
|
38
|
+
fn drop(&mut self) {
|
|
39
|
+
if !self.keep {
|
|
40
|
+
let _ = std::fs::remove_dir_all(&self.home);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pub(crate) fn copilot_home() -> Result<PathBuf, ProviderError> {
|
|
46
|
+
if let Some(home) = std::env::var_os("COPILOT_HOME").filter(|value| !value.is_empty()) {
|
|
47
|
+
return Ok(PathBuf::from(home));
|
|
48
|
+
}
|
|
49
|
+
std::env::var_os("HOME")
|
|
50
|
+
.map(PathBuf::from)
|
|
51
|
+
.map(|home| home.join(".copilot"))
|
|
52
|
+
.ok_or_else(|| ProviderError::Io("HOME is unset; cannot resolve COPILOT_HOME".to_string()))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pub(crate) fn materialize_copilot_fork(
|
|
56
|
+
workspace: &Path,
|
|
57
|
+
agent_id: &str,
|
|
58
|
+
source_session_id: &SessionId,
|
|
59
|
+
) -> Result<CopilotForkMaterialization, ProviderError> {
|
|
60
|
+
let source_home = copilot_home()?;
|
|
61
|
+
let source_dir = source_home
|
|
62
|
+
.join("session-state")
|
|
63
|
+
.join(source_session_id.as_str());
|
|
64
|
+
let source_db = source_home.join("session-store.db");
|
|
65
|
+
if !source_dir.is_dir() || !source_db.is_file() {
|
|
66
|
+
return Err(ProviderError::Io(format!(
|
|
67
|
+
"copilot source backing is incomplete under {}",
|
|
68
|
+
source_home.display()
|
|
69
|
+
)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let session_id = SessionId::new(next_session_token());
|
|
73
|
+
let parent = workspace
|
|
74
|
+
.join(".team/runtime/provider-session-forks/copilot")
|
|
75
|
+
.join(agent_id);
|
|
76
|
+
let home = parent.join(session_id.as_str());
|
|
77
|
+
let temp = parent.join(format!(
|
|
78
|
+
".{}.tmp-{}",
|
|
79
|
+
session_id.as_str(),
|
|
80
|
+
std::process::id()
|
|
81
|
+
));
|
|
82
|
+
if home.exists() || temp.exists() {
|
|
83
|
+
return Err(ProviderError::Io(format!(
|
|
84
|
+
"copilot fork destination already exists: {}",
|
|
85
|
+
home.display()
|
|
86
|
+
)));
|
|
87
|
+
}
|
|
88
|
+
std::fs::create_dir_all(temp.join("session-state"))
|
|
89
|
+
.map_err(|error| ProviderError::Io(error.to_string()))?;
|
|
90
|
+
|
|
91
|
+
let result = (|| {
|
|
92
|
+
let target_session_dir = temp.join("session-state").join(session_id.as_str());
|
|
93
|
+
copy_tree(&source_dir, &target_session_dir)?;
|
|
94
|
+
rewrite_text_tree(
|
|
95
|
+
&target_session_dir,
|
|
96
|
+
source_session_id.as_str(),
|
|
97
|
+
session_id.as_str(),
|
|
98
|
+
)?;
|
|
99
|
+
rewrite_per_session_db(
|
|
100
|
+
&target_session_dir.join("session.db"),
|
|
101
|
+
source_session_id.as_str(),
|
|
102
|
+
session_id.as_str(),
|
|
103
|
+
)?;
|
|
104
|
+
clone_and_rekey_store(
|
|
105
|
+
&source_db,
|
|
106
|
+
&temp.join("session-store.db"),
|
|
107
|
+
source_session_id.as_str(),
|
|
108
|
+
session_id.as_str(),
|
|
109
|
+
)?;
|
|
110
|
+
std::fs::rename(&temp, &home).map_err(|error| ProviderError::Io(error.to_string()))
|
|
111
|
+
})();
|
|
112
|
+
if let Err(error) = result {
|
|
113
|
+
let _ = std::fs::remove_dir_all(&temp);
|
|
114
|
+
return Err(error);
|
|
115
|
+
}
|
|
116
|
+
Ok(CopilotForkMaterialization {
|
|
117
|
+
session_id,
|
|
118
|
+
home,
|
|
119
|
+
keep: false,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
fn copy_tree(source: &Path, target: &Path) -> Result<(), ProviderError> {
|
|
124
|
+
std::fs::create_dir_all(target).map_err(|error| ProviderError::Io(error.to_string()))?;
|
|
125
|
+
for entry in std::fs::read_dir(source).map_err(|error| ProviderError::Io(error.to_string()))? {
|
|
126
|
+
let entry = entry.map_err(|error| ProviderError::Io(error.to_string()))?;
|
|
127
|
+
let source_path = entry.path();
|
|
128
|
+
let target_path = target.join(entry.file_name());
|
|
129
|
+
if source_path.is_dir() {
|
|
130
|
+
copy_tree(&source_path, &target_path)?;
|
|
131
|
+
} else {
|
|
132
|
+
std::fs::copy(&source_path, &target_path)
|
|
133
|
+
.map_err(|error| ProviderError::Io(error.to_string()))?;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
Ok(())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fn rewrite_text_tree(root: &Path, source: &str, target: &str) -> Result<(), ProviderError> {
|
|
140
|
+
for entry in std::fs::read_dir(root).map_err(|error| ProviderError::Io(error.to_string()))? {
|
|
141
|
+
let entry = entry.map_err(|error| ProviderError::Io(error.to_string()))?;
|
|
142
|
+
let path = entry.path();
|
|
143
|
+
if path.is_dir() {
|
|
144
|
+
rewrite_text_tree(&path, source, target)?;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if path.extension().and_then(|ext| ext.to_str()) == Some("db") {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
151
|
+
continue;
|
|
152
|
+
};
|
|
153
|
+
if text.contains(source) {
|
|
154
|
+
std::fs::write(&path, text.replace(source, target))
|
|
155
|
+
.map_err(|error| ProviderError::Io(error.to_string()))?;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
Ok(())
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
fn clone_and_rekey_store(
|
|
162
|
+
source_db: &Path,
|
|
163
|
+
target_db: &Path,
|
|
164
|
+
source: &str,
|
|
165
|
+
target: &str,
|
|
166
|
+
) -> Result<(), ProviderError> {
|
|
167
|
+
let source_conn = Connection::open(source_db).map_err(sql_error)?;
|
|
168
|
+
source_conn
|
|
169
|
+
.execute("VACUUM INTO ?1", [target_db.to_string_lossy().as_ref()])
|
|
170
|
+
.map_err(sql_error)?;
|
|
171
|
+
drop(source_conn);
|
|
172
|
+
|
|
173
|
+
let mut conn = Connection::open(target_db).map_err(sql_error)?;
|
|
174
|
+
conn.pragma_update(None, "foreign_keys", "OFF")
|
|
175
|
+
.map_err(sql_error)?;
|
|
176
|
+
let tx = conn.transaction().map_err(sql_error)?;
|
|
177
|
+
let session_rows = tx
|
|
178
|
+
.execute(
|
|
179
|
+
"update sessions set id = ?1 where id = ?2",
|
|
180
|
+
params![target, source],
|
|
181
|
+
)
|
|
182
|
+
.map_err(sql_error)?;
|
|
183
|
+
if session_rows != 1 {
|
|
184
|
+
return Err(ProviderError::Io(format!(
|
|
185
|
+
"copilot session-store source row count must be 1, got {session_rows}"
|
|
186
|
+
)));
|
|
187
|
+
}
|
|
188
|
+
for table in SESSION_TABLES {
|
|
189
|
+
require_session_column(&tx, table)?;
|
|
190
|
+
tx.execute(
|
|
191
|
+
&format!("update {table} set session_id = ?1 where session_id = ?2"),
|
|
192
|
+
params![target, source],
|
|
193
|
+
)
|
|
194
|
+
.map_err(sql_error)?;
|
|
195
|
+
}
|
|
196
|
+
tx.commit().map_err(sql_error)?;
|
|
197
|
+
verify_rekeyed_store(&conn, source, target)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fn require_session_column(conn: &Connection, table: &str) -> Result<(), ProviderError> {
|
|
201
|
+
let mut stmt = conn
|
|
202
|
+
.prepare(&format!("pragma table_info({table})"))
|
|
203
|
+
.map_err(sql_error)?;
|
|
204
|
+
let mut rows = stmt.query([]).map_err(sql_error)?;
|
|
205
|
+
while let Some(row) = rows.next().map_err(sql_error)? {
|
|
206
|
+
let name: String = row.get(1).map_err(sql_error)?;
|
|
207
|
+
if name == "session_id" {
|
|
208
|
+
return Ok(());
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
Err(ProviderError::Io(format!(
|
|
212
|
+
"copilot session-store table {table} is missing session_id"
|
|
213
|
+
)))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
fn verify_rekeyed_store(
|
|
217
|
+
conn: &Connection,
|
|
218
|
+
source: &str,
|
|
219
|
+
target: &str,
|
|
220
|
+
) -> Result<(), ProviderError> {
|
|
221
|
+
let target_sessions: i64 = conn
|
|
222
|
+
.query_row(
|
|
223
|
+
"select count(*) from sessions where id = ?1",
|
|
224
|
+
[target],
|
|
225
|
+
|row| row.get(0),
|
|
226
|
+
)
|
|
227
|
+
.map_err(sql_error)?;
|
|
228
|
+
let source_sessions: i64 = conn
|
|
229
|
+
.query_row(
|
|
230
|
+
"select count(*) from sessions where id = ?1",
|
|
231
|
+
[source],
|
|
232
|
+
|row| row.get(0),
|
|
233
|
+
)
|
|
234
|
+
.map_err(sql_error)?;
|
|
235
|
+
if target_sessions != 1 || source_sessions != 0 {
|
|
236
|
+
return Err(ProviderError::Io(
|
|
237
|
+
"copilot session-store session id rekey verification failed".to_string(),
|
|
238
|
+
));
|
|
239
|
+
}
|
|
240
|
+
for table in SESSION_TABLES {
|
|
241
|
+
let remaining: i64 = conn
|
|
242
|
+
.query_row(
|
|
243
|
+
&format!("select count(*) from {table} where session_id = ?1"),
|
|
244
|
+
[source],
|
|
245
|
+
|row| row.get(0),
|
|
246
|
+
)
|
|
247
|
+
.map_err(sql_error)?;
|
|
248
|
+
if remaining != 0 {
|
|
249
|
+
return Err(ProviderError::Io(format!(
|
|
250
|
+
"copilot session-store table {table} still references source session"
|
|
251
|
+
)));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
Ok(())
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
fn rewrite_per_session_db(path: &Path, source: &str, target: &str) -> Result<(), ProviderError> {
|
|
258
|
+
if !path.is_file() {
|
|
259
|
+
return Ok(());
|
|
260
|
+
}
|
|
261
|
+
let mut conn = Connection::open(path).map_err(sql_error)?;
|
|
262
|
+
let columns: Vec<(String, String)> = {
|
|
263
|
+
let mut tables = conn
|
|
264
|
+
.prepare("select name from sqlite_master where type = 'table'")
|
|
265
|
+
.map_err(sql_error)?;
|
|
266
|
+
let names = tables
|
|
267
|
+
.query_map([], |row| row.get::<_, String>(0))
|
|
268
|
+
.map_err(sql_error)?
|
|
269
|
+
.collect::<Result<Vec<_>, _>>()
|
|
270
|
+
.map_err(sql_error)?;
|
|
271
|
+
let mut columns = Vec::new();
|
|
272
|
+
for table in names {
|
|
273
|
+
let mut stmt = conn
|
|
274
|
+
.prepare(&format!(
|
|
275
|
+
"pragma table_info('{}')",
|
|
276
|
+
table.replace('\'', "''")
|
|
277
|
+
))
|
|
278
|
+
.map_err(sql_error)?;
|
|
279
|
+
let rows = stmt
|
|
280
|
+
.query_map([], |row| row.get::<_, String>(1))
|
|
281
|
+
.map_err(sql_error)?;
|
|
282
|
+
for column in rows {
|
|
283
|
+
let column = column.map_err(sql_error)?;
|
|
284
|
+
if column == "session_id" || column == "recipient_session_id" {
|
|
285
|
+
columns.push((table.clone(), column));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
columns
|
|
290
|
+
};
|
|
291
|
+
let tx = conn.transaction().map_err(sql_error)?;
|
|
292
|
+
for (table, column) in columns {
|
|
293
|
+
let table = table.replace('"', "\"\"");
|
|
294
|
+
let column = column.replace('"', "\"\"");
|
|
295
|
+
tx.execute(
|
|
296
|
+
&format!("update \"{table}\" set \"{column}\" = ?1 where \"{column}\" = ?2"),
|
|
297
|
+
params![target, source],
|
|
298
|
+
)
|
|
299
|
+
.map_err(sql_error)?;
|
|
300
|
+
}
|
|
301
|
+
tx.commit().map_err(sql_error)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
fn sql_error(error: rusqlite::Error) -> ProviderError {
|
|
305
|
+
ProviderError::Io(format!("copilot sqlite: {error}"))
|
|
306
|
+
}
|