feat(entities): inbox + cross-project (project="") support (Issue C)
Makes `inbox` a reserved pseudo-project and `project=""` a first-class
cross-project bucket. Unblocks AKC capturing pre-project leads/quotes
and cross-project facts (materials, vendors) that don't fit a single
registered project.
- projects/registry.py: INBOX_PROJECT/GLOBAL_PROJECT constants,
is_reserved_project(), register/update guards, resolve_project_name
passthrough for "inbox"
- engineering/service.py: get_entities scoping rules (inbox-only,
global-only, real+global default, scope_only=true strict).
promote_entity accepts target_project to retarget on promote
- api/routes.py: GET /entities gains scope_only; POST /entities accepts
project=null as ""; POST /entities/{id}/promote accepts
{target_project, note}
- engineering/wiki.py: homepage shows "Inbox & Global" cards with live
counts linking to scoped lists
- tests/test_inbox_crossproject.py: 15 tests (reserved enforcement,
scoping rules, API shape, promote retargeting)
- DEV-LEDGER.md: session log, test_count 463 -> 478
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -333,9 +333,20 @@ def _set_entity_status(
|
||||
return True
|
||||
|
||||
|
||||
def promote_entity(entity_id: str, actor: str = "api", note: str = "") -> bool:
|
||||
def promote_entity(
|
||||
entity_id: str,
|
||||
actor: str = "api",
|
||||
note: str = "",
|
||||
target_project: str | None = None,
|
||||
) -> bool:
|
||||
"""Promote a candidate entity to active.
|
||||
|
||||
When ``target_project`` is provided (Issue C), also retarget the
|
||||
entity's project before flipping the status. Use this to graduate an
|
||||
inbox/global lead into a real project (e.g. when a vendor quote
|
||||
becomes a contract). ``target_project`` is canonicalized through the
|
||||
registry; reserved ids (``inbox``) and ``""`` are accepted verbatim.
|
||||
|
||||
Phase 5F graduation hook: if this entity has source_refs pointing at
|
||||
memories (format "memory:<uuid>"), mark those source memories as
|
||||
``status=graduated`` and set their ``graduated_to_entity_id`` forward
|
||||
@@ -346,6 +357,27 @@ def promote_entity(entity_id: str, actor: str = "api", note: str = "") -> bool:
|
||||
if entity is None or entity.status != "candidate":
|
||||
return False
|
||||
|
||||
if target_project is not None:
|
||||
new_project = (
|
||||
resolve_project_name(target_project) if target_project else ""
|
||||
)
|
||||
if new_project != entity.project:
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE entities SET project = ?, updated_at = ? "
|
||||
"WHERE id = ?",
|
||||
(new_project, now, entity_id),
|
||||
)
|
||||
_audit_entity(
|
||||
entity_id=entity_id,
|
||||
action="retargeted",
|
||||
actor=actor,
|
||||
before={"project": entity.project},
|
||||
after={"project": new_project},
|
||||
note=note,
|
||||
)
|
||||
|
||||
ok = _set_entity_status(entity_id, "active", actor=actor, note=note)
|
||||
if not ok:
|
||||
return False
|
||||
@@ -470,7 +502,24 @@ def get_entities(
|
||||
status: str = "active",
|
||||
name_contains: str | None = None,
|
||||
limit: int = 100,
|
||||
scope_only: bool = False,
|
||||
) -> list[Entity]:
|
||||
"""List entities with optional filters.
|
||||
|
||||
Project scoping rules (Issue C — inbox + cross-project):
|
||||
|
||||
- ``project=None``: no project filter, return everything matching status.
|
||||
- ``project=""``: return only cross-project (global) entities.
|
||||
- ``project="inbox"``: return only inbox entities.
|
||||
- ``project="<real>"`` and ``scope_only=False`` (default): return entities
|
||||
scoped to that project PLUS cross-project (``project=""``) entities.
|
||||
- ``project="<real>"`` and ``scope_only=True``: return only that project,
|
||||
without the cross-project bleed.
|
||||
"""
|
||||
from atocore.projects.registry import (
|
||||
INBOX_PROJECT, GLOBAL_PROJECT, is_reserved_project,
|
||||
)
|
||||
|
||||
query = "SELECT * FROM entities WHERE status = ?"
|
||||
params: list = [status]
|
||||
|
||||
@@ -478,8 +527,14 @@ def get_entities(
|
||||
query += " AND entity_type = ?"
|
||||
params.append(entity_type)
|
||||
if project is not None:
|
||||
query += " AND project = ?"
|
||||
params.append(project)
|
||||
p = (project or "").strip()
|
||||
if p == GLOBAL_PROJECT or is_reserved_project(p) or scope_only:
|
||||
query += " AND project = ?"
|
||||
params.append(p)
|
||||
else:
|
||||
# Real project — include cross-project entities by default.
|
||||
query += " AND (project = ? OR project = ?)"
|
||||
params.extend([p, GLOBAL_PROJECT])
|
||||
if name_contains:
|
||||
query += " AND name LIKE ?"
|
||||
params.append(f"%{name_contains}%")
|
||||
|
||||
@@ -23,7 +23,11 @@ from atocore.engineering.service import (
|
||||
get_relationships,
|
||||
)
|
||||
from atocore.memory.service import get_memories
|
||||
from atocore.projects.registry import load_project_registry
|
||||
from atocore.projects.registry import (
|
||||
GLOBAL_PROJECT,
|
||||
INBOX_PROJECT,
|
||||
load_project_registry,
|
||||
)
|
||||
|
||||
|
||||
_TOP_NAV_LINKS = [
|
||||
@@ -147,6 +151,43 @@ def render_homepage() -> str:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Issue C: Inbox + Global pseudo-projects alongside registered projects.
|
||||
# scope_only=True keeps real-project entities out of these counts.
|
||||
try:
|
||||
inbox_count = len(get_entities(
|
||||
project=INBOX_PROJECT, scope_only=True, limit=500,
|
||||
))
|
||||
global_count = len(get_entities(
|
||||
project=GLOBAL_PROJECT, scope_only=True, limit=500,
|
||||
))
|
||||
except Exception:
|
||||
inbox_count = 0
|
||||
global_count = 0
|
||||
|
||||
lines.append('<h2>📥 Inbox & Global</h2>')
|
||||
lines.append(
|
||||
'<p class="emerging-intro">Entities that don\'t belong to a specific '
|
||||
'project yet. <strong>Inbox</strong> holds pre-project leads and quotes. '
|
||||
'<strong>Global</strong> holds cross-project facts (material properties, '
|
||||
'vendor capabilities) that apply everywhere.</p>'
|
||||
)
|
||||
lines.append('<div class="card-grid">')
|
||||
lines.append(
|
||||
f'<a href="/entities?project=inbox&scope_only=true" class="card">'
|
||||
f'<h3>📥 Inbox</h3>'
|
||||
f'<p>Pre-project leads, quotes, early conversations.</p>'
|
||||
f'<div class="stats">{inbox_count} entities</div>'
|
||||
f'</a>'
|
||||
)
|
||||
lines.append(
|
||||
f'<a href="/entities?project=&scope_only=true" class="card">'
|
||||
f'<h3>🌐 Global</h3>'
|
||||
f'<p>Cross-project facts: materials, vendors, shared knowledge.</p>'
|
||||
f'<div class="stats">{global_count} entities</div>'
|
||||
f'</a>'
|
||||
)
|
||||
lines.append('</div>')
|
||||
|
||||
for bucket_name, items in buckets.items():
|
||||
if not items:
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user