Skip to content

Commit 59e4e2f

Browse files
committed
Fix blueprint round-trip portable fields
1 parent 68db08b commit 59e4e2f

6 files changed

Lines changed: 336 additions & 5 deletions

File tree

docs/reference/features.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ cccc actor secrets <actor_id> --keys
229229

230230
CCCC Web supports blueprint export/import for portable group setup.
231231

232-
- Export captures actors, settings, automation rules/snippets, and guidance overrides.
232+
- Export captures actors, actor startup autoload baselines, group settings/feature toggles, automation rules/snippets, and guidance overrides.
233233
- Import uses replace semantics (applies the incoming configuration as the new group setup).
234234
- Ledger history is preserved (import does not rewrite historical events).
235235
- Environment secrets are intentionally excluded.

src/cccc/contracts/v1/group_template.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class GroupTemplateActor(BaseModel):
2626
runner: RunnerKind = "pty"
2727
command: Union[str, List[str]] = Field(default_factory=list)
2828
submit: ActorSubmit = "enter"
29+
capability_autoload: List[str] = Field(default_factory=list)
2930
enabled: bool = True
3031

3132
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -54,6 +55,8 @@ class GroupTemplateSettings(BaseModel):
5455
terminal_transcript_visibility: TerminalTranscriptVisibility = "foreman"
5556
terminal_transcript_notify_tail: bool = True
5657
terminal_transcript_notify_lines: int = 20
58+
panorama_enabled: bool = False
59+
desktop_pet_enabled: bool = False
5760

5861
model_config = ConfigDict(extra="ignore")
5962

src/cccc/daemon/ops/template_ops.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ def _slug_filename(value: str) -> str:
4646
s = re.sub(r"[^a-zA-Z0-9]+", "-", str(value or "").strip()).strip("-").lower()
4747
return s or "group"
4848

49+
50+
def _normalize_capability_id_list(raw: Any) -> List[str]:
51+
out: List[str] = []
52+
if not isinstance(raw, list):
53+
return out
54+
seen: set[str] = set()
55+
for item in raw:
56+
cap_id = str(item or "").strip()
57+
if not cap_id or cap_id in seen:
58+
continue
59+
seen.add(cap_id)
60+
out.append(cap_id)
61+
return out
62+
63+
4964
def _remove_runner_state_files(group_id: str, actor_id: str) -> None:
5065
home = ensure_home()
5166
gid = str(group_id or "").strip()
@@ -98,6 +113,14 @@ def group_template_export(args: Dict[str, Any]) -> DaemonResponse:
98113
actor_tpl.runner = str(profile.get("runner") or actor_tpl.runner) # type: ignore[assignment]
99114
actor_tpl.command = list(profile.get("command") or [])
100115
actor_tpl.submit = str(profile.get("submit") or actor_tpl.submit) # type: ignore[assignment]
116+
actor_autoload = _normalize_capability_id_list(actor_doc.get("capability_autoload"))
117+
defaults = profile.get("capability_defaults") if isinstance(profile.get("capability_defaults"), dict) else {}
118+
profile_autoload = []
119+
# Templates materialize linked actors as portable custom actors. Snapshot only
120+
# actor-scoped profile defaults here; session-scoped autoload remains runtime-specific.
121+
if str(defaults.get("default_scope") or "actor").strip().lower() == "actor":
122+
profile_autoload = _normalize_capability_id_list(defaults.get("autoload_capabilities"))
123+
actor_tpl.capability_autoload = [*profile_autoload, *[cap for cap in actor_autoload if cap not in profile_autoload]]
101124
except Exception as e:
102125
return _error("template_export_failed", str(e))
103126
text = dump_group_template(tpl)
@@ -151,6 +174,7 @@ def _prompt_preview(kind: str, limit: int = 2000) -> Dict[str, Any]:
151174
"runner": a.runner,
152175
"command": a.command,
153176
"submit": a.submit,
177+
"capability_autoload": list(a.capability_autoload or []),
154178
"enabled": bool(a.enabled),
155179
}
156180
for a in tpl.actors
@@ -268,6 +292,12 @@ def _int(k: str, *, min_v: int = 0, max_v: Optional[int] = None) -> None:
268292
n = 20
269293
patch["terminal_transcript_notify_lines"] = max(1, min(80, n))
270294

295+
# Group feature toggles
296+
if "panorama_enabled" in settings:
297+
patch["panorama_enabled"] = coerce_bool(settings.get("panorama_enabled"), default=False)
298+
if "desktop_pet_enabled" in settings:
299+
patch["desktop_pet_enabled"] = coerce_bool(settings.get("desktop_pet_enabled"), default=False)
300+
271301
delivery_keys = {"min_interval_seconds", "auto_mark_on_delivery"}
272302
automation_keys = {
273303
"nudge_after_seconds",
@@ -285,6 +315,7 @@ def _int(k: str, *, min_v: int = 0, max_v: Optional[int] = None) -> None:
285315
"help_nudge_min_messages",
286316
}
287317
messaging_keys = {"default_send_to"}
318+
feature_keys = {"panorama_enabled", "desktop_pet_enabled"}
288319

289320
delivery = group.doc.get("delivery") if isinstance(group.doc.get("delivery"), dict) else {}
290321
automation = group.doc.get("automation") if isinstance(group.doc.get("automation"), dict) else {}
@@ -304,6 +335,12 @@ def _int(k: str, *, min_v: int = 0, max_v: Optional[int] = None) -> None:
304335
group.doc["delivery"] = delivery
305336
group.doc["automation"] = automation
306337
group.doc["messaging"] = messaging
338+
if feature_keys & set(patch.keys()):
339+
features = group.doc.get("features") if isinstance(group.doc.get("features"), dict) else {}
340+
for k in feature_keys:
341+
if k in patch:
342+
features[k] = coerce_bool(patch.get(k), default=False)
343+
group.doc["features"] = features
307344

308345
tt_patch: Dict[str, Any] = {}
309346
if "terminal_transcript_visibility" in patch:
@@ -462,6 +499,7 @@ def group_template_import_replace(args: Dict[str, Any]) -> DaemonResponse:
462499
"enabled": bool(actor_tpl.enabled),
463500
"submit": str(actor_tpl.submit or "enter"),
464501
"command": _normalize_template_actor_command(actor_tpl),
502+
"capability_autoload": _normalize_capability_id_list(actor_tpl.capability_autoload),
465503
}
466504

467505
existing = find_actor(group, aid)
@@ -484,6 +522,7 @@ def group_template_import_replace(args: Dict[str, Any]) -> DaemonResponse:
484522
env={},
485523
default_scope_key="",
486524
submit=str(patch.get("submit") or "enter"), # type: ignore[arg-type]
525+
capability_autoload=list(patch.get("capability_autoload") or []),
487526
enabled=bool(patch.get("enabled", True)),
488527
runner=str(patch.get("runner") or "pty"), # type: ignore[arg-type]
489528
runtime=str(patch.get("runtime") or "codex"), # type: ignore[arg-type]

src/cccc/kernel/group_template.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ def _as_int(v: Any, default: int) -> int:
4040
return int(default)
4141

4242

43+
def _normalize_capability_id_list(raw: Any) -> List[str]:
44+
out: List[str] = []
45+
if not isinstance(raw, list):
46+
return out
47+
seen: set[str] = set()
48+
for item in raw:
49+
cap_id = str(item or "").strip()
50+
if not cap_id or cap_id in seen:
51+
continue
52+
seen.add(cap_id)
53+
out.append(cap_id)
54+
return out
55+
56+
4357
def parse_group_template(text: str) -> GroupTemplate:
4458
raw = str(text or "").strip()
4559
if not raw:
@@ -80,12 +94,14 @@ def build_group_template_from_group(group: Group, *, cccc_version: str = "") ->
8094
"runner": str(a.get("runner") or "pty"),
8195
"command": list(a.get("command") or []) if isinstance(a.get("command"), list) else [],
8296
"submit": str(a.get("submit") or "enter"),
97+
"capability_autoload": _normalize_capability_id_list(a.get("capability_autoload")),
8398
"enabled": coerce_bool(a.get("enabled"), default=True),
8499
}
85100
)
86101

87102
automation = group.doc.get("automation") if isinstance(group.doc.get("automation"), dict) else {}
88103
delivery = group.doc.get("delivery") if isinstance(group.doc.get("delivery"), dict) else {}
104+
features = group.doc.get("features") if isinstance(group.doc.get("features"), dict) else {}
89105
tt = get_terminal_transcript_settings(group.doc)
90106
default_send_to = get_default_send_to(group.doc)
91107

@@ -109,6 +125,8 @@ def build_group_template_from_group(group: Group, *, cccc_version: str = "") ->
109125
"terminal_transcript_visibility": str(tt.get("visibility") or "foreman"),
110126
"terminal_transcript_notify_tail": coerce_bool(tt.get("notify_tail"), default=True),
111127
"terminal_transcript_notify_lines": _as_int(tt.get("notify_lines", 20), 20),
128+
"panorama_enabled": coerce_bool(features.get("panorama_enabled"), default=False),
129+
"desktop_pet_enabled": coerce_bool(features.get("desktop_pet_enabled"), default=False),
112130
}
113131

114132
def _prompt_value(filename: str) -> Optional[str]:
@@ -190,6 +208,10 @@ def preview_group_template_replace(group: Group, template: GroupTemplate) -> Gro
190208
changed = True
191209
if str(cur.get("submit") or "enter") != str(a.submit or "enter"):
192210
changed = True
211+
cur_autoload = _normalize_capability_id_list(cur.get("capability_autoload"))
212+
new_autoload = _normalize_capability_id_list(a.capability_autoload)
213+
if cur_autoload != new_autoload:
214+
changed = True
193215
cur_cmd = cur.get("command")
194216
cur_cmd_list = [str(x).strip() for x in cur_cmd] if isinstance(cur_cmd, list) else []
195217
new_cmd_list = _normalize_command(a.command)
@@ -201,6 +223,7 @@ def preview_group_template_replace(group: Group, template: GroupTemplate) -> Gro
201223
# Settings diff (we only compare keys we export/import).
202224
automation = group.doc.get("automation") if isinstance(group.doc.get("automation"), dict) else {}
203225
delivery = group.doc.get("delivery") if isinstance(group.doc.get("delivery"), dict) else {}
226+
features = group.doc.get("features") if isinstance(group.doc.get("features"), dict) else {}
204227
tt = get_terminal_transcript_settings(group.doc)
205228
default_send_to = get_default_send_to(group.doc)
206229
current_settings: Dict[str, Any] = {
@@ -223,6 +246,8 @@ def preview_group_template_replace(group: Group, template: GroupTemplate) -> Gro
223246
"terminal_transcript_visibility": str(tt.get("visibility") or "foreman"),
224247
"terminal_transcript_notify_tail": coerce_bool(tt.get("notify_tail"), default=True),
225248
"terminal_transcript_notify_lines": _as_int(tt.get("notify_lines", 20), 20),
249+
"panorama_enabled": coerce_bool(features.get("panorama_enabled"), default=False),
250+
"desktop_pet_enabled": coerce_bool(features.get("desktop_pet_enabled"), default=False),
226251
}
227252
desired_settings = template.settings.model_dump()
228253
settings_changed: Dict[str, Tuple[Any, Any]] = {}

0 commit comments

Comments
 (0)