code-context-control 2.56.1__py3-none-any.whl → 2.57.0__py3-none-any.whl
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.
- cli/c3.py +1 -1
- cli/guide/workflow.html +87 -0
- cli/hub_server.py +162 -5
- cli/hub_ui/app.js +1 -1
- cli/hub_ui/components/config_editor.js +126 -2
- cli/hub_ui/components/drill_panel.js +35 -6
- cli/hub_ui/components/drill_subprojects.js +514 -0
- cli/hub_ui/components/drill_views.js +32 -20
- cli/hub_ui/components/global_search.js +62 -9
- cli/hub_ui/components/modals.js +483 -6
- cli/hub_ui/components/project_card.js +179 -15
- cli/hub_ui/components/toasts.js +9 -2
- cli/hub_ui/state.js +4 -0
- cli/ui/api.js +8 -0
- {code_context_control-2.56.1.dist-info → code_context_control-2.57.0.dist-info}/METADATA +10 -2
- {code_context_control-2.56.1.dist-info → code_context_control-2.57.0.dist-info}/RECORD +20 -19
- {code_context_control-2.56.1.dist-info → code_context_control-2.57.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.56.1.dist-info → code_context_control-2.57.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.56.1.dist-info → code_context_control-2.57.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.56.1.dist-info → code_context_control-2.57.0.dist-info}/top_level.txt +0 -0
cli/c3.py
CHANGED
cli/guide/workflow.html
CHANGED
|
@@ -249,6 +249,7 @@
|
|
|
249
249
|
<a href="#plan-mode" class="sidebar-link"><span class="icon">📐</span> Plan Mode</a>
|
|
250
250
|
<a href="#session-restart" class="sidebar-link"><span class="icon">🔄</span> Session Restart</a>
|
|
251
251
|
<a href="#terse-skill" class="sidebar-link"><span class="icon">⚡</span> /terse Skill</a>
|
|
252
|
+
<a href="#subprojects" class="sidebar-link"><span class="icon">🌳</span> Sub-projects</a>
|
|
252
253
|
<a href="#native-fallback" class="sidebar-link"><span class="icon">⚠️</span> Native Fallback</a>
|
|
253
254
|
</div>
|
|
254
255
|
</aside>
|
|
@@ -355,6 +356,12 @@
|
|
|
355
356
|
<td><code>c3_project(action='list')</code></td>
|
|
356
357
|
<td>v2.31.0 — discover and operate on OTHER c3-installed projects; reads run free, writes need <code>allow_write=true</code>. <a href="tools.html#c3_project">Tool card →</a></td>
|
|
357
358
|
</tr>
|
|
359
|
+
<tr>
|
|
360
|
+
<td><span class="badge badge-purple">+</span></td>
|
|
361
|
+
<td><strong>Sub-projects</strong></td>
|
|
362
|
+
<td><code>c3 sub add <folder></code></td>
|
|
363
|
+
<td>v2.44.0 — link child projects living inside a parent; federated search/memory + Hub management. <a href="#subprojects">Section →</a></td>
|
|
364
|
+
</tr>
|
|
358
365
|
</tbody>
|
|
359
366
|
</table>
|
|
360
367
|
</section>
|
|
@@ -897,6 +904,86 @@ c3_session(action=<span class="str">'restore'</span>)</code></pre>
|
|
|
897
904
|
|
|
898
905
|
<hr class="divider">
|
|
899
906
|
|
|
907
|
+
<!-- Sub-projects -->
|
|
908
|
+
<section class="section" id="subprojects">
|
|
909
|
+
<div class="section-title"><span class="icon">🌳</span> Sub-projects</div>
|
|
910
|
+
|
|
911
|
+
<p>Since v2.44.0, a c3 project can link folders that physically live inside it as <strong>sub-projects</strong>. Each child gets its own full <code>.c3</code> (index, memory, config), the parent's index excludes the child's subtree, and the link is recorded in three places at once: the parent config's <code>subprojects[]</code> array, a back-link in the child's config, and the hub registry. Links are depth-1 — a child cannot have children of its own.</p>
|
|
912
|
+
|
|
913
|
+
<div class="callout callout-tip">
|
|
914
|
+
<span class="callout-icon">💡</span>
|
|
915
|
+
<div class="callout-body">
|
|
916
|
+
Use sub-projects for monorepos and nested apps: each child stays independently searchable with its own memory, while the parent can fan queries out across the whole family.
|
|
917
|
+
</div>
|
|
918
|
+
</div>
|
|
919
|
+
|
|
920
|
+
<h4>CLI — <code>c3 sub</code></h4>
|
|
921
|
+
<table class="table">
|
|
922
|
+
<thead><tr><th>Command</th><th>Effect</th></tr></thead>
|
|
923
|
+
<tbody>
|
|
924
|
+
<tr>
|
|
925
|
+
<td><code>c3 sub add <folder> [--name --ide]</code></td>
|
|
926
|
+
<td>Link a folder inside the current project. Adopts an existing <code>.c3</code>, otherwise runs a full init. <code>--no-init</code> links without initializing; <code>--parent <path></code> targets a different parent.</td>
|
|
927
|
+
</tr>
|
|
928
|
+
<tr>
|
|
929
|
+
<td><code>c3 sub list</code></td>
|
|
930
|
+
<td>Tree of linked children with status (the default when no sub-command is given).</td>
|
|
931
|
+
</tr>
|
|
932
|
+
<tr>
|
|
933
|
+
<td><code>c3 sub remove <ref> [--clear]</code></td>
|
|
934
|
+
<td>Unlink a child and promote it to a standalone top-level project (its <code>.c3</code> is kept). <code>--clear</code> also wipes <code>.c3</code>, uninstalls the MCP config and instruction docs, and deregisters it.</td>
|
|
935
|
+
</tr>
|
|
936
|
+
<tr>
|
|
937
|
+
<td><code>c3 sub run <update|reindex|health> [--include-parent]</code></td>
|
|
938
|
+
<td>Cascade an operation across every child. <code>--mcp</code> also reinstalls the MCP config during <code>update</code>.</td>
|
|
939
|
+
</tr>
|
|
940
|
+
<tr>
|
|
941
|
+
<td><code>c3 sub check [--fix --prune]</code></td>
|
|
942
|
+
<td>Reconcile the three-way links. <code>--fix</code> repairs from the parent config; adding <code>--prune</code> drops entries whose folder is gone.</td>
|
|
943
|
+
</tr>
|
|
944
|
+
</tbody>
|
|
945
|
+
</table>
|
|
946
|
+
|
|
947
|
+
<h4>Agent surface — the <code>scope</code> parameter</h4>
|
|
948
|
+
<p><code>c3_search</code> and <code>c3_memory</code> accept a <code>scope</code> parameter on parent projects:</p>
|
|
949
|
+
<table class="table">
|
|
950
|
+
<thead><tr><th>Value</th><th>Meaning</th></tr></thead>
|
|
951
|
+
<tbody>
|
|
952
|
+
<tr><td><code>''</code></td><td>Current project only (default)</td></tr>
|
|
953
|
+
<tr><td><code>'all'</code></td><td>Fan out across the parent and all linked children</td></tr>
|
|
954
|
+
<tr><td><code>'<child>'</code></td><td>One specific sub-project by name</td></tr>
|
|
955
|
+
</tbody>
|
|
956
|
+
</table>
|
|
957
|
+
|
|
958
|
+
<pre><code>c3_search(query=<span class="str">'auth middleware'</span>, scope=<span class="str">'all'</span>)
|
|
959
|
+
c3_memory(action=<span class="str">'recall'</span>, query=<span class="str">'payment gotchas'</span>, scope=<span class="str">'billing-service'</span>)</code></pre>
|
|
960
|
+
|
|
961
|
+
<p>With <code>hybrid.subprojects.memory_rollup</code> enabled, a parent-side recall also unions in the children's facts automatically.</p>
|
|
962
|
+
|
|
963
|
+
<h4>Hub UI management (v2.57.0)</h4>
|
|
964
|
+
<p>Everything the CLI does — plus the operations that need multi-step safety — is available in the Hub:</p>
|
|
965
|
+
<ul>
|
|
966
|
+
<li><strong>Designate</strong> — kebab menu → "Designate sub-project…" on any project card, or from the drill-in <strong>Sub-projects</strong> tab. A folder picker fenced to the parent's subtree validates the choice, shows adopt-vs-initialize expectations up front, and offers an instruction-docs/IDE picker (<code>auto | claude | vscode | cursor | codex | antigravity</code>).</li>
|
|
967
|
+
<li><strong>"Make sub-project of…"</strong> — the reverse move: a top-level card whose folder physically sits inside a registered parent can link itself under it (containment is mandatory).</li>
|
|
968
|
+
<li><strong>Sub-projects drill tab</strong> — per-child rows with inline <em>Validate</em> and <em>Promote</em>; inline <em>Reconcile</em> with per-issue detail and planned repair actions; a <em>Cascade</em> launcher with operation picker, include-parent toggle, affected-children preview, live progress, and Cancel.</li>
|
|
969
|
+
<li><strong>Link health, passively</strong> — parents show a red "N link issues" badge and children a status badge (<code>ok · backlink_broken · unregistered · missing_folder · missing_c3 · orphan</code>), computed from config + filesystem on every listing — no manual reconcile needed to spot trouble.</li>
|
|
970
|
+
<li><strong>"Change parent…"</strong> — a staged wizard, because folders must physically move: ready-now targets relink immediately; otherwise unlink → move the folder yourself → validate → relink, with a per-step checklist and Undo before the move.</li>
|
|
971
|
+
<li><strong>"De-initialize…"</strong> — clear-mode removal behind a typed-name confirmation: deletes <code>.c3</code>, uninstalls the MCP config, removes instruction docs, deregisters. Your code is untouched.</li>
|
|
972
|
+
<li><strong>Federation toggles</strong> — the config editor's Sub-projects section on parent projects: memory roll-up, search fan-out for <code>scope='all'</code>, and max children per query, persisted under <code>hybrid.subprojects</code>.</li>
|
|
973
|
+
<li><strong>Ctrl-K scope chips</strong> — global search can target All, Top-level only, or one parent + its children; child results carry a "parent › child" breadcrumb.</li>
|
|
974
|
+
</ul>
|
|
975
|
+
|
|
976
|
+
<div class="callout callout-warning">
|
|
977
|
+
<span class="callout-icon">⚠️</span>
|
|
978
|
+
<div class="callout-body">
|
|
979
|
+
<strong>Managed keys</strong>
|
|
980
|
+
The raw <code>subprojects</code> / <code>parent</code> config keys render read-only in the Hub config editor by design — hierarchy changes only go through the dedicated actions above (CLI or Hub), never by hand-editing the link records.
|
|
981
|
+
</div>
|
|
982
|
+
</div>
|
|
983
|
+
</section>
|
|
984
|
+
|
|
985
|
+
<hr class="divider">
|
|
986
|
+
|
|
900
987
|
<!-- Native fallback -->
|
|
901
988
|
<section class="section" id="native-fallback">
|
|
902
989
|
<div class="section-title"><span class="icon">⚠️</span> When Native Tools Are Permitted</div>
|
cli/hub_server.py
CHANGED
|
@@ -333,6 +333,7 @@ _HUB_JS_FILES = [
|
|
|
333
333
|
"hub_ui/components/session_drawer.js",
|
|
334
334
|
"hub_ui/components/drill_panel.js",
|
|
335
335
|
"hub_ui/components/drill_views.js",
|
|
336
|
+
"hub_ui/components/drill_subprojects.js",
|
|
336
337
|
"hub_ui/components/drill_health.js",
|
|
337
338
|
"hub_ui/components/drill_tasks.js",
|
|
338
339
|
"hub_ui/components/drill_artifacts.js",
|
|
@@ -477,6 +478,129 @@ def _notification_count(project_path: str) -> int:
|
|
|
477
478
|
return count
|
|
478
479
|
|
|
479
480
|
|
|
481
|
+
_sub_links_cache = {"fp": None, "ts": 0.0, "data": None, "computing": False}
|
|
482
|
+
_sub_links_lock = threading.Lock()
|
|
483
|
+
_SUB_LINKS_TTL = 15.0
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _invalidate_subproject_links():
|
|
487
|
+
"""Drop the cached link-health snapshot (call after any hierarchy mutation)."""
|
|
488
|
+
with _sub_links_lock:
|
|
489
|
+
_sub_links_cache.update({"fp": None, "ts": 0.0, "data": None})
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _sub_federation_errors(sub):
|
|
493
|
+
"""Schema check for the hybrid.subprojects federation dict (config PUT)."""
|
|
494
|
+
allowed = {"memory_rollup", "search_fanout", "max_children_per_query"}
|
|
495
|
+
unknown = sorted(set(sub) - allowed)
|
|
496
|
+
if unknown:
|
|
497
|
+
return f"unknown keys {unknown}"
|
|
498
|
+
for k in ("memory_rollup", "search_fanout"):
|
|
499
|
+
if k in sub and not isinstance(sub[k], bool):
|
|
500
|
+
return f"'{k}' must be a boolean"
|
|
501
|
+
v = sub.get("max_children_per_query")
|
|
502
|
+
if v is not None and (isinstance(v, bool) or not isinstance(v, int) or not 1 <= v <= 256):
|
|
503
|
+
return "'max_children_per_query' must be an integer between 1 and 256"
|
|
504
|
+
return ""
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _norm_realpath(s):
|
|
508
|
+
"""resolve()+normcase, matching services.subprojects._norm — plain
|
|
509
|
+
normcase alone would flag symlinked/8.3/trailing-slash registry rows
|
|
510
|
+
as false orphans that Reconcile then denies."""
|
|
511
|
+
try:
|
|
512
|
+
return os.path.normcase(str(Path(s).resolve()))
|
|
513
|
+
except Exception:
|
|
514
|
+
return os.path.normcase(str(s or ""))
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _compute_subproject_links(projects):
|
|
518
|
+
"""Statuses only — deliberately NOT SubprojectManager.list(), which
|
|
519
|
+
also parses facts.json and notifications.jsonl per child; those counts
|
|
520
|
+
were computed and thrown away on every poll recompute."""
|
|
521
|
+
from services.subprojects import SubprojectManager, get_subprojects
|
|
522
|
+
parents_out, children_out = {}, {}
|
|
523
|
+
registry = None
|
|
524
|
+
for parent in projects:
|
|
525
|
+
if not (parent.get("is_parent") or parent.get("subproject_count")):
|
|
526
|
+
continue
|
|
527
|
+
try:
|
|
528
|
+
mgr = SubprojectManager(parent.get("path", ""))
|
|
529
|
+
entries = get_subprojects(mgr.parent_path)
|
|
530
|
+
if registry is None:
|
|
531
|
+
registry = mgr.pm._read_projects()
|
|
532
|
+
except Exception:
|
|
533
|
+
continue
|
|
534
|
+
issues = 0
|
|
535
|
+
config_paths = set()
|
|
536
|
+
for entry in entries:
|
|
537
|
+
try:
|
|
538
|
+
key = _norm_realpath(mgr._abs_child(entry.get("rel_path", "")))
|
|
539
|
+
status = mgr._entry_status(entry, registry)
|
|
540
|
+
except Exception:
|
|
541
|
+
continue
|
|
542
|
+
config_paths.add(key)
|
|
543
|
+
if status != "ok":
|
|
544
|
+
issues += 1
|
|
545
|
+
children_out[key] = status
|
|
546
|
+
parent_key = _norm_realpath(parent.get("path", ""))
|
|
547
|
+
for row in projects:
|
|
548
|
+
rp = row.get("parent_path")
|
|
549
|
+
if (rp and _norm_realpath(rp) == parent_key
|
|
550
|
+
and _norm_realpath(row.get("path", "")) not in config_paths):
|
|
551
|
+
issues += 1
|
|
552
|
+
children_out[_norm_realpath(row.get("path", ""))] = "orphan"
|
|
553
|
+
if issues:
|
|
554
|
+
parents_out[parent_key] = issues
|
|
555
|
+
return {"parents": parents_out, "children": children_out}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _annotate_subproject_links(projects):
|
|
559
|
+
"""Per-parent link-health rollup + per-child link_status for the cards.
|
|
560
|
+
|
|
561
|
+
SubprojectManager.list() is config + filesystem + raw-registry reads
|
|
562
|
+
only (no child runtimes, no port probing). Registry orphans (rows
|
|
563
|
+
claiming a parent that has no matching config entry) count as issues
|
|
564
|
+
too — same rule as reconcile. The snapshot is cached _SUB_LINKS_TTL
|
|
565
|
+
seconds and computed single-flight, so overlapping UI polls never
|
|
566
|
+
stack behind a slow disk (e.g. a disconnected network drive); the
|
|
567
|
+
fingerprint ties the cache to the registry rows, and mutating
|
|
568
|
+
sub-project endpoints call _invalidate_subproject_links().
|
|
569
|
+
"""
|
|
570
|
+
fp = tuple(sorted((os.path.normcase(p.get("path", "")),
|
|
571
|
+
os.path.normcase(p.get("parent_path") or ""))
|
|
572
|
+
for p in projects))
|
|
573
|
+
now = time.time()
|
|
574
|
+
do_compute = False
|
|
575
|
+
with _sub_links_lock:
|
|
576
|
+
snap = _sub_links_cache["data"]
|
|
577
|
+
fresh = (snap is not None and _sub_links_cache["fp"] == fp
|
|
578
|
+
and now - _sub_links_cache["ts"] < _SUB_LINKS_TTL)
|
|
579
|
+
if not fresh:
|
|
580
|
+
if _sub_links_cache["computing"]:
|
|
581
|
+
if _sub_links_cache["fp"] != fp:
|
|
582
|
+
snap = None # stale snapshot of a different registry — skip
|
|
583
|
+
else:
|
|
584
|
+
_sub_links_cache["computing"] = True
|
|
585
|
+
do_compute = True
|
|
586
|
+
if do_compute:
|
|
587
|
+
try:
|
|
588
|
+
snap = _compute_subproject_links(projects)
|
|
589
|
+
with _sub_links_lock:
|
|
590
|
+
_sub_links_cache.update({"fp": fp, "ts": time.time(), "data": snap})
|
|
591
|
+
finally:
|
|
592
|
+
with _sub_links_lock:
|
|
593
|
+
_sub_links_cache["computing"] = False
|
|
594
|
+
if not snap:
|
|
595
|
+
return
|
|
596
|
+
for p in projects:
|
|
597
|
+
key = _norm_realpath(p.get("path", ""))
|
|
598
|
+
if key in snap["parents"]:
|
|
599
|
+
p["subproject_issues"] = snap["parents"][key]
|
|
600
|
+
if key in snap["children"]:
|
|
601
|
+
p["link_status"] = snap["children"][key]
|
|
602
|
+
|
|
603
|
+
|
|
480
604
|
@app.route("/api/projects", methods=["GET"])
|
|
481
605
|
def api_projects_list():
|
|
482
606
|
try:
|
|
@@ -487,6 +611,7 @@ def api_projects_list():
|
|
|
487
611
|
p["notification_count"] = _notification_count(p.get("path", ""))
|
|
488
612
|
p["is_parent"] = os.path.normcase(p.get("path", "")) in parent_paths
|
|
489
613
|
p["open_task_count"] = open_task_count(p.get("path", ""))
|
|
614
|
+
_annotate_subproject_links(projects)
|
|
490
615
|
return jsonify(projects)
|
|
491
616
|
except Exception as e:
|
|
492
617
|
return jsonify({"error": str(e)}), 500
|
|
@@ -519,6 +644,8 @@ def api_projects_remove():
|
|
|
519
644
|
orphans = [p.get("path") for p in _pm().list_projects()
|
|
520
645
|
if p.get("parent_path") and os.path.normcase(p["parent_path"]) == resolved]
|
|
521
646
|
result = {"removed": _pm().remove_project(path)}
|
|
647
|
+
if result["removed"]:
|
|
648
|
+
_invalidate_subproject_links()
|
|
522
649
|
if result["removed"] and orphans:
|
|
523
650
|
result["orphaned_children"] = orphans
|
|
524
651
|
return jsonify(result)
|
|
@@ -1226,6 +1353,8 @@ def api_subprojects_add():
|
|
|
1226
1353
|
res = _run_c3(args, timeout=300)
|
|
1227
1354
|
payload = _parse_json_tail(res.get("output", ""))
|
|
1228
1355
|
ok = res.get("success") and bool((payload or {}).get("added"))
|
|
1356
|
+
if ok:
|
|
1357
|
+
_invalidate_subproject_links()
|
|
1229
1358
|
body = {"success": ok, "result": payload, "output": res.get("output", "")}
|
|
1230
1359
|
return jsonify(body), (201 if ok else 500)
|
|
1231
1360
|
|
|
@@ -1247,6 +1376,8 @@ def api_subprojects_remove():
|
|
|
1247
1376
|
res = _run_c3(args, timeout=300)
|
|
1248
1377
|
payload = _parse_json_tail(res.get("output", ""))
|
|
1249
1378
|
ok = res.get("success") and bool((payload or {}).get("removed"))
|
|
1379
|
+
if ok:
|
|
1380
|
+
_invalidate_subproject_links()
|
|
1250
1381
|
body = {"success": ok, "result": payload, "output": res.get("output", "")}
|
|
1251
1382
|
return jsonify(body), (200 if ok else 500)
|
|
1252
1383
|
|
|
@@ -1259,8 +1390,11 @@ def api_subprojects_reconcile():
|
|
|
1259
1390
|
if not parent:
|
|
1260
1391
|
return jsonify({"error": "parent is required"}), 400
|
|
1261
1392
|
try:
|
|
1262
|
-
|
|
1263
|
-
fix=bool(data.get("fix")), prune=bool(data.get("prune")))
|
|
1393
|
+
result = _sub_manager(parent).reconcile(
|
|
1394
|
+
fix=bool(data.get("fix")), prune=bool(data.get("prune")))
|
|
1395
|
+
if data.get("fix"):
|
|
1396
|
+
_invalidate_subproject_links()
|
|
1397
|
+
return jsonify(result)
|
|
1264
1398
|
except ValueError as e:
|
|
1265
1399
|
return jsonify({"error": str(e)}), 404
|
|
1266
1400
|
except Exception as e:
|
|
@@ -1321,6 +1455,7 @@ def _sub_cascade_worker(op: str, targets: list):
|
|
|
1321
1455
|
_sub_cascade_state["running"] = False
|
|
1322
1456
|
_sub_cascade_state["done"] = True
|
|
1323
1457
|
_sub_cascade_state["current"] = None
|
|
1458
|
+
_invalidate_subproject_links()
|
|
1324
1459
|
|
|
1325
1460
|
|
|
1326
1461
|
@app.route("/api/projects/subprojects/cascade", methods=["POST"])
|
|
@@ -1339,11 +1474,20 @@ def api_subprojects_cascade():
|
|
|
1339
1474
|
return jsonify({"error": "op must be update|reindex|health"}), 400
|
|
1340
1475
|
try:
|
|
1341
1476
|
sm = _sub_manager(parent)
|
|
1342
|
-
|
|
1477
|
+
listed = sm.list()
|
|
1478
|
+
# Skipped children (missing folder; missing .c3 unless the op can
|
|
1479
|
+
# create it) are excluded up front so total and the success
|
|
1480
|
+
# denominator reflect real work — previously they inflated total
|
|
1481
|
+
# and reported as failures.
|
|
1482
|
+
skipped = [t for t in listed
|
|
1483
|
+
if t.get("status") == "missing_folder"
|
|
1484
|
+
or (t.get("status") == "missing_c3" and op != "update")]
|
|
1485
|
+
targets = [t for t in listed if t not in skipped]
|
|
1343
1486
|
if data.get("include_parent"):
|
|
1344
1487
|
targets.append({"name": "(parent)", "path": sm.parent_path, "status": "ok"})
|
|
1345
1488
|
if not targets:
|
|
1346
|
-
return jsonify({"error": "No sub-projects
|
|
1489
|
+
return jsonify({"error": "No cascade-eligible sub-projects"
|
|
1490
|
+
+ (f" ({len(skipped)} skipped)" if skipped else "")}), 400
|
|
1347
1491
|
with _sub_cascade_lock:
|
|
1348
1492
|
_sub_cascade_state = {
|
|
1349
1493
|
"running": True, "cancelled": False, "results": [],
|
|
@@ -1352,7 +1496,8 @@ def api_subprojects_cascade():
|
|
|
1352
1496
|
}
|
|
1353
1497
|
t = threading.Thread(target=_sub_cascade_worker, args=(op, targets), daemon=True)
|
|
1354
1498
|
t.start()
|
|
1355
|
-
return jsonify({"started": True, "total": len(targets), "op": op
|
|
1499
|
+
return jsonify({"started": True, "total": len(targets), "op": op,
|
|
1500
|
+
"skipped": len(skipped)})
|
|
1356
1501
|
except ValueError as e:
|
|
1357
1502
|
return jsonify({"error": str(e)}), 404
|
|
1358
1503
|
except Exception as e:
|
|
@@ -1643,6 +1788,18 @@ def api_projects_config_put():
|
|
|
1643
1788
|
return jsonify({"error": "values must be an object"}), 400
|
|
1644
1789
|
for refused in _CONFIG_REFUSED_KEYS:
|
|
1645
1790
|
if refused in values:
|
|
1791
|
+
# 'subprojects' under the hybrid section is the federation
|
|
1792
|
+
# settings DICT (memory_rollup / search_fanout / …), not the
|
|
1793
|
+
# top-level child-links ARRAY — the array stays refused (and is
|
|
1794
|
+
# unreachable through the section whitelist anyway). Schema-check
|
|
1795
|
+
# the dict: exactly these keys, exactly these types — the generic
|
|
1796
|
+
# deep-merge below would happily persist anything.
|
|
1797
|
+
if (section == "hybrid" and refused == "subprojects"
|
|
1798
|
+
and isinstance(values["subprojects"], dict)):
|
|
1799
|
+
bad = _sub_federation_errors(values["subprojects"])
|
|
1800
|
+
if bad:
|
|
1801
|
+
return jsonify({"error": f"hybrid.subprojects: {bad}"}), 400
|
|
1802
|
+
continue
|
|
1646
1803
|
return jsonify({"error": f"'{refused}' cannot be edited here"}), 400
|
|
1647
1804
|
try:
|
|
1648
1805
|
resolved = _resolve_project_path(path)
|
cli/hub_ui/app.js
CHANGED
|
@@ -128,7 +128,7 @@ function App() {
|
|
|
128
128
|
{drawerProject &&
|
|
129
129
|
<SessionDrawer project={drawerProject} onClose={() => setDrawerProject(null)} />}
|
|
130
130
|
{drill &&
|
|
131
|
-
<DrillPanel project={drill} tab={drillTab} setTab={setDrillTab}
|
|
131
|
+
<DrillPanel project={drill} tab={drillTab} setTab={setDrillTab} projects={projects}
|
|
132
132
|
onClose={() => setDrill(null)} onChanged={loadProjects} onOpenModal={openModal} />}
|
|
133
133
|
<GlobalSearch open={searchOpen} onClose={() => setSearchOpen(false)} projects={projects}
|
|
134
134
|
onOpenProject={(p, tab) => { setSearchOpen(false); openDrill(p, tab || 'overview'); }} />
|
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
const CFG_ED_SECTIONS = ['hybrid', 'agents', 'delegate', 'proxy', 'mcp', 'meta', 'memory_llm', 'bitbucket'];
|
|
7
7
|
const CFG_ED_READONLY = ['bitbucket'];
|
|
8
8
|
const CFG_ED_REFUSED = ['version', 'project_path', 'permission_tier', 'subprojects', 'parent', 'api_key'];
|
|
9
|
+
// Refused keys worth surfacing read-only when present, rather than hiding —
|
|
10
|
+
// still never editable here (server refuses the write regardless).
|
|
11
|
+
const CFG_ED_MANAGED = ['subprojects', 'parent'];
|
|
12
|
+
const CFG_ED_MANAGED_HINT = 'Managed via sub-project actions — use the project card menu or drill-in panel.';
|
|
13
|
+
// hybrid.subprojects federation settings get a friendly "Sub-projects"
|
|
14
|
+
// section on parent projects. The hub API accepts dict-valued 'subprojects'
|
|
15
|
+
// under section=hybrid (schema-checked server-side); the read-only fallback
|
|
16
|
+
// below only engages if this flag is ever flipped off again.
|
|
17
|
+
const CFG_ED_SUBPROJECTS_WRITABLE = true;
|
|
9
18
|
|
|
10
19
|
function ConfigEditor({ project }) {
|
|
11
20
|
const [section, setSection] = useState('hybrid');
|
|
@@ -36,11 +45,43 @@ function ConfigEditor({ project }) {
|
|
|
36
45
|
};
|
|
37
46
|
useEffect(() => { setCfg(null); load(); }, [project.path, section]);
|
|
38
47
|
|
|
48
|
+
// ── Sub-projects (hybrid.subprojects): friendly federation controls ─────
|
|
49
|
+
// Shown for parent projects on the hybrid section. Edits nest under one
|
|
50
|
+
// 'subprojects' key holding only the changed sub-keys, so the server's
|
|
51
|
+
// deep-merge preserves untouched siblings (e.g. parent_memory_visible).
|
|
52
|
+
const isParent = !!project.is_parent || (project.subproject_count || 0) > 0;
|
|
53
|
+
const showSubprojects = section === 'hybrid' && !!cfg
|
|
54
|
+
&& (isParent || cfg.subprojects !== undefined);
|
|
55
|
+
const subCfg = (cfg && cfg.subprojects && typeof cfg.subprojects === 'object') ? cfg.subprojects : {};
|
|
56
|
+
const subDefs = (defs.subprojects && typeof defs.subprojects === 'object') ? defs.subprojects : {};
|
|
57
|
+
const subEdits = (edits.subprojects && typeof edits.subprojects === 'object') ? edits.subprojects : {};
|
|
58
|
+
const subVal = (k) => Object.prototype.hasOwnProperty.call(subEdits, k) ? subEdits[k]
|
|
59
|
+
: (subCfg[k] !== undefined ? subCfg[k] : subDefs[k]);
|
|
60
|
+
const subEdited = (k) => Object.prototype.hasOwnProperty.call(subEdits, k);
|
|
61
|
+
const subDot = (k) => subEdited(k)
|
|
62
|
+
? <span style={{ width: 6, height: 6, borderRadius: '50%', background: T.accent, display: 'inline-block', flexShrink: 0 }} />
|
|
63
|
+
: null;
|
|
64
|
+
const setSubEdit = (k, v) => {
|
|
65
|
+
if (!CFG_ED_SUBPROJECTS_WRITABLE) return;
|
|
66
|
+
setEdits(e => {
|
|
67
|
+
const cur = Object.assign({}, (e.subprojects && typeof e.subprojects === 'object') ? e.subprojects : {});
|
|
68
|
+
cur[k] = v;
|
|
69
|
+
return Object.assign({}, e, { subprojects: cur });
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
39
73
|
const keys = [];
|
|
74
|
+
const managedKeys = [];
|
|
40
75
|
if (cfg) {
|
|
41
|
-
Object.keys(defs).forEach(k => {
|
|
76
|
+
Object.keys(defs).forEach(k => {
|
|
77
|
+
if (CFG_ED_REFUSED.indexOf(k) < 0) keys.push(k);
|
|
78
|
+
else if (CFG_ED_MANAGED.indexOf(k) >= 0 && managedKeys.indexOf(k) < 0
|
|
79
|
+
&& !(k === 'subprojects' && showSubprojects)) managedKeys.push(k);
|
|
80
|
+
});
|
|
42
81
|
Object.keys(cfg).forEach(k => {
|
|
43
|
-
if (CFG_ED_REFUSED.indexOf(k) < 0
|
|
82
|
+
if (CFG_ED_REFUSED.indexOf(k) < 0) { if (keys.indexOf(k) < 0) keys.push(k); }
|
|
83
|
+
else if (CFG_ED_MANAGED.indexOf(k) >= 0 && managedKeys.indexOf(k) < 0
|
|
84
|
+
&& !(k === 'subprojects' && showSubprojects)) managedKeys.push(k);
|
|
44
85
|
});
|
|
45
86
|
}
|
|
46
87
|
const currentVal = (k) => Object.prototype.hasOwnProperty.call(edits, k) ? edits[k]
|
|
@@ -66,6 +107,14 @@ function ConfigEditor({ project }) {
|
|
|
66
107
|
v = Number(v);
|
|
67
108
|
if (!isFinite(v)) throw new Error(`'${k}' must be a number`);
|
|
68
109
|
}
|
|
110
|
+
if (k === 'subprojects' && v && typeof v === 'object'
|
|
111
|
+
&& Object.prototype.hasOwnProperty.call(v, 'max_children_per_query')) {
|
|
112
|
+
const n = Number(v.max_children_per_query);
|
|
113
|
+
if (!isFinite(n) || n < 1 || Math.floor(n) !== n) {
|
|
114
|
+
throw new Error("'max_children_per_query' must be a positive integer");
|
|
115
|
+
}
|
|
116
|
+
v = Object.assign({}, v, { max_children_per_query: n });
|
|
117
|
+
}
|
|
69
118
|
values[k] = v;
|
|
70
119
|
});
|
|
71
120
|
const d = await api.put('/api/projects/config', { path: project.path, section, values });
|
|
@@ -83,6 +132,13 @@ function ConfigEditor({ project }) {
|
|
|
83
132
|
Object.keys(defs).forEach(k => {
|
|
84
133
|
if (CFG_ED_REFUSED.indexOf(k) < 0) next[k] = JSON.parse(JSON.stringify(defs[k] === undefined ? null : defs[k]));
|
|
85
134
|
});
|
|
135
|
+
if (showSubprojects && CFG_ED_SUBPROJECTS_WRITABLE) {
|
|
136
|
+
next.subprojects = {
|
|
137
|
+
memory_rollup: subDefs.memory_rollup !== false,
|
|
138
|
+
search_fanout: subDefs.search_fanout !== false,
|
|
139
|
+
max_children_per_query: subDefs.max_children_per_query !== undefined ? subDefs.max_children_per_query : 8,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
86
142
|
setEdits(next);
|
|
87
143
|
setJsonText({}); setJsonBad({});
|
|
88
144
|
notify(`Prefilled ${section} defaults — review, then Save section`, 'warn');
|
|
@@ -91,6 +147,18 @@ function ConfigEditor({ project }) {
|
|
|
91
147
|
const dirty = Object.keys(edits).length > 0;
|
|
92
148
|
const invalid = Object.keys(jsonBad).some(k => jsonBad[k]);
|
|
93
149
|
|
|
150
|
+
const renderManagedField = (k) => {
|
|
151
|
+
const val = cfg && cfg[k] !== undefined ? cfg[k] : defs[k];
|
|
152
|
+
const text = (val !== null && typeof val === 'object')
|
|
153
|
+
? JSON.stringify(val) : String(val === undefined ? '' : val);
|
|
154
|
+
return (
|
|
155
|
+
<div key={k} style={{ display: 'flex', gap: 12, alignItems: 'flex-start', padding: '6px 0', opacity: 0.55 }}>
|
|
156
|
+
<span className="mono" style={{ fontSize: 11, color: T.textDim, width: 200, flexShrink: 0, overflowWrap: 'anywhere' }}>{k}</span>
|
|
157
|
+
<span className="mono" style={{ fontSize: 11, color: T.textDim, flex: 1, overflowWrap: 'anywhere' }}>{text}</span>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
94
162
|
const renderField = (k) => {
|
|
95
163
|
const kind = kindOf(k);
|
|
96
164
|
const val = currentVal(k);
|
|
@@ -215,6 +283,62 @@ function ConfigEditor({ project }) {
|
|
|
215
283
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
216
284
|
{keys.map(renderField)}
|
|
217
285
|
</div>
|
|
286
|
+
{showSubprojects && (
|
|
287
|
+
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${T.border}` }}>
|
|
288
|
+
<div style={{ fontSize: 12, fontWeight: 700, color: T.text }}>Sub-projects</div>
|
|
289
|
+
<div style={{ fontSize: 11, color: T.textDim, margin: '2px 0 4px' }}>
|
|
290
|
+
How this parent federates with its linked children (<span className="mono">hybrid.subprojects</span>).
|
|
291
|
+
</div>
|
|
292
|
+
{!CFG_ED_SUBPROJECTS_WRITABLE && (
|
|
293
|
+
<div style={{ fontSize: 11, color: T.textMuted, marginBottom: 4 }}>
|
|
294
|
+
Read-only for now — the hub API refuses <span className="mono">subprojects</span> config writes.
|
|
295
|
+
Edit <span className="mono">.c3/config.json</span> directly to change these.
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
<div style={CFG_ED_SUBPROJECTS_WRITABLE ? undefined : { opacity: 0.55, pointerEvents: 'none' }}>
|
|
299
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
300
|
+
<div style={{ flex: 1 }}>
|
|
301
|
+
{renderBoolToggle('Parent memory recall includes child facts', !!subVal('memory_rollup'),
|
|
302
|
+
() => setSubEdit('memory_rollup', !subVal('memory_rollup')),
|
|
303
|
+
`memory_rollup — default: ${subDefs.memory_rollup === false ? 'off' : 'on'}`)}
|
|
304
|
+
</div>
|
|
305
|
+
{subDot('memory_rollup')}
|
|
306
|
+
</div>
|
|
307
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
308
|
+
<div style={{ flex: 1 }}>
|
|
309
|
+
{renderBoolToggle("Search scope 'all' fans out to children", !!subVal('search_fanout'),
|
|
310
|
+
() => setSubEdit('search_fanout', !subVal('search_fanout')),
|
|
311
|
+
`search_fanout — default: ${subDefs.search_fanout === false ? 'off' : 'on'}`)}
|
|
312
|
+
</div>
|
|
313
|
+
{subDot('search_fanout')}
|
|
314
|
+
</div>
|
|
315
|
+
<label style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '8px 0' }}>
|
|
316
|
+
<span style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }}>
|
|
317
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
318
|
+
<span style={{ color: T.textMuted, fontSize: 12 }}>Max children per query</span>
|
|
319
|
+
{subDot('max_children_per_query')}
|
|
320
|
+
</span>
|
|
321
|
+
<span style={{ color: T.textDim, fontSize: 11 }}>
|
|
322
|
+
max_children_per_query — cap per federated call, default: {subDefs.max_children_per_query !== undefined ? subDefs.max_children_per_query : 8}
|
|
323
|
+
</span>
|
|
324
|
+
</span>
|
|
325
|
+
<input type="number" min={1} step={1} disabled={!CFG_ED_SUBPROJECTS_WRITABLE}
|
|
326
|
+
value={subVal('max_children_per_query') == null ? '' : String(subVal('max_children_per_query'))}
|
|
327
|
+
onChange={ev => setSubEdit('max_children_per_query', ev.target.value)}
|
|
328
|
+
className="mono"
|
|
329
|
+
style={drillFieldStyle({ width: 110, textAlign: 'right', fontSize: 11 })} />
|
|
330
|
+
</label>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
{managedKeys.length > 0 && (
|
|
335
|
+
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${T.border}` }}>
|
|
336
|
+
<div style={{ fontSize: 11, color: T.textDim, marginBottom: 8 }}>{CFG_ED_MANAGED_HINT}</div>
|
|
337
|
+
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
338
|
+
{managedKeys.map(renderManagedField)}
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
218
342
|
{!readOnly && (
|
|
219
343
|
<div style={{
|
|
220
344
|
display: 'flex', alignItems: 'center', gap: 10, marginTop: 20,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const DRILL_PANEL_TABS = [
|
|
7
7
|
['overview', 'Overview'],
|
|
8
|
+
['subprojects', 'Sub-projects'],
|
|
8
9
|
['tasks', 'Tasks'],
|
|
9
10
|
['artifacts', 'Artifacts'],
|
|
10
11
|
['memory', 'Memory'],
|
|
@@ -106,9 +107,24 @@ function DrillNeedsInit({ project, onReady }) {
|
|
|
106
107
|
);
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
function DrillPanel({ project, tab, setTab, onClose, onChanged, onOpenModal }) {
|
|
110
|
+
function DrillPanel({ project, tab, setTab, onClose, onChanged, onOpenModal, projects }) {
|
|
111
|
+
// Children can't have children (depth-1) — hide the Sub-projects tab for them.
|
|
112
|
+
const isChild = !!project.parent_path;
|
|
113
|
+
const tabs = DRILL_PANEL_TABS.filter(([id]) => id !== 'subprojects' || !isChild);
|
|
114
|
+
// "under <parent>" chip: friendly name from the loaded project list when
|
|
115
|
+
// available (optional prop), else the last path segment.
|
|
116
|
+
const parentName = (() => {
|
|
117
|
+
if (!isChild) return null;
|
|
118
|
+
const norm = (s) => String(s || '').replace(/[\\/]+$/, '').toLowerCase();
|
|
119
|
+
const row = (projects || []).find(pr => norm(pr.path) === norm(project.parent_path));
|
|
120
|
+
if (row && row.name) return row.name;
|
|
121
|
+
const segs = String(project.parent_path).split(/[\\/]/).filter(Boolean);
|
|
122
|
+
return segs[segs.length - 1] || project.parent_path;
|
|
123
|
+
})();
|
|
110
124
|
const renderTab = () => {
|
|
111
125
|
switch (tab) {
|
|
126
|
+
case 'subprojects':
|
|
127
|
+
return <DrillSubprojects project={project} onChanged={onChanged} onOpenModal={onOpenModal} />;
|
|
112
128
|
case 'tasks': return <DrillTasks project={project} onChanged={onChanged} />;
|
|
113
129
|
case 'artifacts': return <DrillArtifacts project={project} onChanged={onChanged} />;
|
|
114
130
|
case 'memory': return <DrillMemory project={project} />;
|
|
@@ -132,10 +148,23 @@ function DrillPanel({ project, tab, setTab, onClose, onChanged, onOpenModal }) {
|
|
|
132
148
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '16px 20px 8px' }}>
|
|
133
149
|
<GlowDot color={project.active ? T.accent : T.textDim} />
|
|
134
150
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
135
|
-
<div style={{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
151
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
|
152
|
+
<div style={{
|
|
153
|
+
fontSize: 15, fontWeight: 700, color: T.text, minWidth: 0,
|
|
154
|
+
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
155
|
+
}}>{project.name || project.path}</div>
|
|
156
|
+
{parentName && (
|
|
157
|
+
<span title={'Sub-project of ' + project.parent_path} style={{
|
|
158
|
+
display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0,
|
|
159
|
+
fontSize: 10, fontWeight: 600, color: T.textMuted, background: T.surfaceAlt,
|
|
160
|
+
border: `1px solid ${T.border}`, borderRadius: 999, padding: '2px 8px',
|
|
161
|
+
whiteSpace: 'nowrap',
|
|
162
|
+
}}>
|
|
163
|
+
<I name="gitBranch" size={10} color={T.textMuted} />
|
|
164
|
+
under {parentName}
|
|
165
|
+
</span>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
139
168
|
<div className="mono" title={project.path} style={{
|
|
140
169
|
fontSize: 11, color: T.textMuted,
|
|
141
170
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
@@ -152,7 +181,7 @@ function DrillPanel({ project, tab, setTab, onClose, onChanged, onOpenModal }) {
|
|
|
152
181
|
display: 'flex', gap: 2, padding: '4px 20px 0',
|
|
153
182
|
borderBottom: `1px solid ${T.border}`, overflowX: 'auto', flexShrink: 0,
|
|
154
183
|
}}>
|
|
155
|
-
{
|
|
184
|
+
{tabs.map(([id, label]) => (
|
|
156
185
|
<button key={id} onClick={() => setTab(id)} style={{
|
|
157
186
|
background: 'none', border: 'none', cursor: 'pointer', padding: '8px 10px',
|
|
158
187
|
fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1,
|