Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ uv.lock
.vscode/
.env
.tmp
/tmp
graphify-out

# Local dev environments
Expand Down
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ cccc im start
| WeCom / 企業微信 | ✅ 対応済み |
| Weixin / 微信 | ✅ 対応済み |

> DingTalkWeCom はストリーミング返信に対応(それぞれ AI Card と aibot ストリーミング)。他のプラットフォームは最終メッセージを配信
> Telegram、Slack、Discord、Feishu、DingTalkWeCom は段階的な返信に対応し、長すぎる結果は欠落のない分割済み最終メッセージへフォールバックします。Weixin は欠落のない最終メッセージを配信し、現在はボットとのダイレクトチャットのみ対応しています

任意の対応プラットフォームから、通常の調整にはプレーンテキストまたは `/send @foreman <メッセージ>` を使い、真のブロードキャストだけ `/send @all <メッセージ>` を使います。`/status` でグループ状態を確認し、`/pause` / `/resume` で運用を制御できます — すべてスマートフォンから。

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ cccc im start
| WeCom / 企业微信 | ✅ Supported |
| Weixin / 微信 | ✅ Supported |

> DingTalk and WeCom support streaming replies (AI Card and aibot streaming respectively); other platforms deliver final messages.
> Telegram, Slack, Discord, Feishu, DingTalk, and WeCom support progressive replies; overlong results fall back to lossless final-message chunks. Weixin delivers lossless final messages and currently supports direct bot chats only.

From any supported platform, use plain text or `/send @foreman <message>` for normal coordination, reserve `/send @all <message>` for true broadcasts, use `/status` to check group health, and use `/pause` / `/resume` to control operations — all from your phone.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ cccc im start
| 企业微信 / WeCom | ✅ 已支持 |
| 微信 / Weixin | ✅ 已支持 |

> 钉钉和企业微信支持流式回复(分别为 AI Card 和 aibot 流式);其余平台投递最终消息
> Telegram、Slack、Discord、飞书、钉钉和企业微信均支持渐进式回复;超长结果会回退为无损分段的最终消息。微信采用无损最终消息投递,目前仅支持机器人私聊

在任一已支持平台上,使用纯文本或 `/send @foreman <消息>` 做常规协调,只有真正广播时才使用 `/send @all <消息>`;也可以用 `/status` 查看组状态,并用 `/pause` / `/resume` 控制运维 — 全部在手机上完成。

Expand Down
4 changes: 2 additions & 2 deletions crates/cccc-cli/src/args/integrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ pub enum ImAction {
Revoke {
#[arg(long)]
chat_id: String,
#[arg(long, default_value_t = 0)]
thread_id: i64,
#[arg(long, default_value = "0")]
thread_id: String,
#[arg(long = "group")]
group_id: Option<String>,
},
Expand Down
17 changes: 16 additions & 1 deletion crates/cccc-contracts/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ pub struct ChatStreamData {
#[serde(default)]
pub reply_to: Option<String>,
#[serde(default)]
pub sender_title: Option<String>,
#[serde(default)]
pub client_id: Option<String>,
}

Expand All @@ -100,7 +102,7 @@ fn snapshot_mode() -> String {

#[cfg(test)]
mod tests {
use super::ChatMessageData;
use super::{ChatMessageData, ChatStreamData};

#[test]
fn chat_message_contract_carries_peer_insight() {
Expand All @@ -114,4 +116,17 @@ mod tests {
Some("reconsider the dependency boundary")
);
}

#[test]
fn chat_stream_contract_carries_sender_title_snapshot() {
let stream: ChatStreamData = serde_json::from_value(serde_json::json!({
"stream_id":"stream-1",
"op":"update",
"text":"partial",
"sender_title":"Review Bot"
}))
.expect("stream contract");

assert_eq!(stream.sender_title.as_deref(), Some("Review Bot"));
}
}
1 change: 1 addition & 0 deletions crates/cccc-core/src/automation_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub fn notify_event(
"rule_id": rule_id,
"text": message,
"to": rule.get("to").cloned().unwrap_or_else(|| json!(["@all"])),
"im_visibility": "public",
"priority": action.and_then(|action| action.get("priority")).cloned().unwrap_or_else(|| json!("normal")),
})
.as_object()
Expand Down
32 changes: 31 additions & 1 deletion crates/cccc-core/src/group_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ fn attach_with(
pub fn detach(store: &GroupStore, group_id: &str, scope_key: &str) -> io::Result<GroupDoc> {
detach_with(store, group_id, scope_key, |result| {
Registry::mutate(store.home(), |registry| {
registry.defaults.remove(scope_key);
if registry.defaults.get(scope_key).map(String::as_str) == Some(group_id) {
registry.defaults.remove(scope_key);
}
if let Some(meta) = registry.groups.get_mut(group_id) {
meta.default_scope_key.clone_from(&result.active_scope_key);
}
Expand Down Expand Up @@ -268,6 +270,34 @@ mod tests {
assert_eq!(detached.actors[0].default_scope_key, "s_next");
}

#[test]
fn detaching_shared_scope_from_non_default_group_preserves_current_default() {
let temp = tempfile::tempdir().expect("tempdir");
let home = HomeLayout::from_path(temp.path().join("home")).expect("home");
let store = GroupStore::new(home).expect("store");
let first = store.create("first", "").expect("first group");
let second = store.create("second", "").expect("second group");
let scope = Scope {
scope_key: "s_shared".into(),
url: temp.path().to_string_lossy().into_owned(),
label: "shared".into(),
git_remote: String::new(),
};
attach(&store, &first.group_id, scope.clone()).expect("attach first");
attach(&store, &second.group_id, scope).expect("attach second");

let detached = detach(&store, &first.group_id, "s_shared").expect("detach first");

assert!(detached.scopes.is_empty());
assert_eq!(
Registry::load(store.home())
.expect("registry")
.defaults
.get("s_shared"),
Some(&second.group_id)
);
}

#[test]
fn failed_attach_rollback_does_not_overwrite_a_concurrent_group_update() {
let temp = tempfile::tempdir().expect("tempdir");
Expand Down
7 changes: 7 additions & 0 deletions crates/cccc-daemon/src/dispatch_concurrency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ mod tests {
)),
Access::GroupRead(group_id) if group_id == "g_one"
));
assert!(matches!(
access(&request(
"terminal_replay",
json!({"group_id":"g_one","actor_id":"peer1"})
)),
Access::GroupRead(group_id) if group_id == "g_one"
));
assert!(matches!(
access(&request("send", json!({"group_id":"g_one"}))),
Access::GroupWrite(group_id) if group_id == "g_one"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ pub(super) fn is_read_only(op: &str) -> bool {
| "presentation_get"
| "task_list"
| "terminal_history"
| "terminal_replay"
| "terminal_since"
| "terminal_snapshot"
| "terminal_status"
Expand Down
42 changes: 32 additions & 10 deletions crates/cccc-daemon/src/ops/actor_runtime/terminal_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ use cccc_core::{GroupStore, HomeLayout, actors, settings};
use cccc_runtime::HistoryConfig;

const DEFAULT_TRANSCRIPT_BYTES: usize = 10 * 1024 * 1024;
const MIN_TRANSCRIPT_BYTES: usize = 1024 * 1024;
const MAX_TRANSCRIPT_BYTES: usize = 200 * 1024 * 1024;
const HOT_BUFFER_BYTES: usize = 512 * 1024;
const MAX_TRANSCRIPT_BYTES: usize = 50_000_000;

pub(super) fn config(
home: &HomeLayout,
Expand All @@ -13,14 +11,18 @@ pub(super) fn config(
) -> std::io::Result<HistoryConfig> {
let actor_id = actors::validate_actor_id(actor_id)?;
let settings = settings::load(home)?;
let max_bytes = settings
let requested_bytes = settings
.observability
.get("terminal_transcript")
.and_then(|value| value.get("per_actor_bytes"))
.and_then(serde_json::Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.unwrap_or(DEFAULT_TRANSCRIPT_BYTES)
.clamp(MIN_TRANSCRIPT_BYTES, MAX_TRANSCRIPT_BYTES);
.unwrap_or(DEFAULT_TRANSCRIPT_BYTES);
let max_bytes = if requested_bytes == 0 {
DEFAULT_TRANSCRIPT_BYTES
} else {
requested_bytes.min(MAX_TRANSCRIPT_BYTES)
};
let persist = settings
.observability
.get("terminal_transcript")
Expand All @@ -37,7 +39,7 @@ pub(super) fn config(
Ok(HistoryConfig {
path: actor_dir.join(format!("{session_id}.pty")),
max_bytes,
hot_bytes: HOT_BUFFER_BYTES.min(max_bytes),
hot_bytes: max_bytes,
persist,
})
}
Expand Down Expand Up @@ -74,7 +76,7 @@ mod tests {
let config = config(&home, &group.group_id, "peer-1").expect("config");

assert_eq!(config.max_bytes, 2 * 1024 * 1024);
assert_eq!(config.hot_bytes, HOT_BUFFER_BYTES);
assert_eq!(config.hot_bytes, 2 * 1024 * 1024);
assert!(!config.persist);
assert!(config.path.starts_with(
actor_dir(&home, &group.group_id, "peer-1").expect("valid actor history directory"),
Expand All @@ -86,7 +88,7 @@ mod tests {
}

#[test]
fn clamps_transcript_limit_to_two_hundred_mib() {
fn clamps_transcript_limit_like_the_python_pty_backlog() {
let temp = tempfile::tempdir().expect("tempdir");
let home = HomeLayout::from_path(temp.path().join("home")).expect("home");
let store = GroupStore::new(home.clone()).expect("store");
Expand All @@ -100,7 +102,27 @@ mod tests {

let config = config(&home, &group.group_id, "peer-1").expect("config");

assert_eq!(config.max_bytes, 200 * 1024 * 1024);
assert_eq!(config.max_bytes, MAX_TRANSCRIPT_BYTES);
assert_eq!(config.hot_bytes, MAX_TRANSCRIPT_BYTES);
}

#[test]
fn zero_transcript_limit_uses_the_python_default() {
let temp = tempfile::tempdir().expect("tempdir");
let home = HomeLayout::from_path(temp.path().join("home")).expect("home");
let store = GroupStore::new(home.clone()).expect("store");
let group = store.create("history-default", "").expect("group");
let mut settings = settings::load(&home).expect("settings");
settings.observability.insert(
"terminal_transcript".into(),
serde_json::json!({"per_actor_bytes": 0}),
);
settings::save(&home, &settings).expect("save");

let config = config(&home, &group.group_id, "peer-1").expect("config");

assert_eq!(config.max_bytes, DEFAULT_TRANSCRIPT_BYTES);
assert_eq!(config.hot_bytes, DEFAULT_TRANSCRIPT_BYTES);
}

#[test]
Expand Down
56 changes: 37 additions & 19 deletions crates/cccc-daemon/src/ops/group_creation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ fn create_using(
})?;
let group_store = store(home)
.map_err(|error| cleanup_directory(&directory, error, "group store initialization"))?;
reject_duplicate_scope(home, &directory, &detected.scope_key)?;
let scope_key = detected.scope_key.clone();
let previous_default = previous_default_group(home, &directory, &scope_key)?;

let title = string_arg(request, "title").unwrap_or_else(|| detected.label.clone());
let topic = string_arg(request, "topic").unwrap_or_default();
Expand All @@ -86,33 +87,27 @@ fn create_using(
"group_id": group.group_id,
"group": super::group_runtime::group(group),
})),
Err(failure) => Err(rollback_create(&group_store, &directory, failure, steps)),
Err(failure) => Err(rollback_create(
home,
&group_store,
&directory,
&scope_key,
previous_default.as_deref(),
failure,
steps,
)),
}
}

fn reject_duplicate_scope(
fn previous_default_group(
home: &HomeLayout,
directory: &PreparedDirectory,
scope_key: &str,
) -> Result<(), OpError> {
) -> Result<Option<String>, OpError> {
let registry = Registry::load(home)
.map_err(OpError::io)
.map_err(|error| cleanup_directory(directory, error, "registry read"))?;
let Some(group_id) = registry.defaults.get(scope_key) else {
return Ok(());
};
let mut error = OpError::new(
"scope_already_attached",
"project directory is already attached to a group",
);
error
.details
.insert("group_id".into(), Value::String(group_id.clone()));
Err(cleanup_directory(
directory,
error,
"duplicate scope detection",
))
Ok(registry.defaults.get(scope_key).cloned())
}

struct CreateFailure {
Expand Down Expand Up @@ -141,17 +136,40 @@ fn finish_create(
}

fn rollback_create(
home: &HomeLayout,
store: &GroupStore,
directory: &PreparedDirectory,
scope_key: &str,
previous_default: Option<&str>,
failure: CreateFailure,
steps: &impl CreationSteps,
) -> OpError {
if let Err(rollback) = steps.rollback(store, &failure.group_id) {
return rollback_error(failure.error, "group", rollback);
}
if let Err(rollback) = restore_previous_default(home, scope_key, previous_default) {
return rollback_error(failure.error, "scope default", rollback);
}
cleanup_directory(directory, failure.error, "project directory")
}

fn restore_previous_default(
home: &HomeLayout,
scope_key: &str,
previous_default: Option<&str>,
) -> io::Result<()> {
Registry::mutate(home, |registry| {
if !registry.defaults.contains_key(scope_key)
&& let Some(group_id) = previous_default.filter(|group_id| !group_id.is_empty())
{
registry
.defaults
.insert(scope_key.to_owned(), group_id.to_owned());
}
Ok(())
})
}

fn cleanup_directory(directory: &PreparedDirectory, original: OpError, stage: &str) -> OpError {
match remove_if_created_empty(directory) {
Ok(()) => original,
Expand Down
37 changes: 37 additions & 0 deletions crates/cccc-daemon/src/ops/group_creation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,43 @@ fn every_failed_stage_restores_all_visible_creation_state() {
}
}

#[test]
fn failed_second_creation_restores_previous_scope_default() {
let temp = tempfile::tempdir().expect("tempdir");
let home = HomeLayout::from_path(temp.path().join("home")).expect("home");
let target = temp.path().join("project");
let first = dispatch(&home, &request(json!({"title":"first","path":target})));
assert!(first.ok, "{:?}", first.error);
let first_id = first.result["group_id"]
.as_str()
.expect("first id")
.to_owned();
let store = GroupStore::new(home.clone()).expect("store");
let first_group = store.load(&first_id).expect("first group");
let scope_key = first_group.active_scope_key.clone();

let failure = create_using(
&home,
&request(json!({"title":"second","path":target})),
&FaultingSteps {
fail_at: FailAt::Ledger,
rollback_fails: false,
},
)
.expect_err("second creation failure");

assert_eq!(failure.code, "io_error");
assert_eq!(store.list().expect("groups").len(), 1);
assert_eq!(
Registry::load(&home)
.expect("registry")
.defaults
.get(&scope_key),
Some(&first_id)
);
assert_eq!(active::get(&home).expect("active"), Some(first_id));
}

#[test]
fn rollback_failure_is_never_hidden_as_the_original_stage_error() {
let temp = tempfile::tempdir().expect("tempdir");
Expand Down
Loading
Loading