ltcai 1.0.1 → 1.2.0

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.
@@ -96,6 +96,16 @@ from latticeai.core.workspace_os import (
96
96
  WorkspaceOSStore,
97
97
  remove_skill_directory,
98
98
  )
99
+ from latticeai.core.enterprise import (
100
+ EnterpriseCapability,
101
+ capability_registry,
102
+ detect_edition,
103
+ )
104
+ from latticeai.services.workspace_service import WorkspaceService
105
+ from latticeai.services.model_service import ModelService
106
+ from latticeai.services.chat_service import ChatService
107
+ from latticeai.api.workspace import create_workspace_router
108
+ from latticeai.api.health import create_health_router
99
109
  from latticeai.core.agent import (
100
110
  AgentState,
101
111
  AgentRunContext,
@@ -352,6 +362,9 @@ SSO_FILE = DATA_DIR / "sso_config.json"
352
362
  KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
353
363
  LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH) if ENABLE_GRAPH else None
354
364
  WORKSPACE_OS = WorkspaceOSStore(DATA_DIR)
365
+ # Service layer (latticeai.services) wraps the store with scope/permission
366
+ # guardrails; routers and the app assembly share this single instance.
367
+ WORKSPACE_SERVICE = WorkspaceService(WORKSPACE_OS)
355
368
 
356
369
  def _require_graph():
357
370
  if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
@@ -722,6 +735,10 @@ def get_history():
722
735
  logging.warning("get_history failed: %s", e)
723
736
  return []
724
737
 
738
+ # Chat service seam: behaviour-preserving façade for history access and
739
+ # Workspace-OS answer-trace recording used by the (unchanged) streaming chat path.
740
+ CHAT_SERVICE = ChatService(store=WORKSPACE_OS, get_history=get_history)
741
+
725
742
  def conversation_title(item: Dict) -> str:
726
743
  content = str(item.get("content") or "").strip()
727
744
  content = re.sub(r"\s+", " ", content)
@@ -1312,22 +1329,8 @@ async def admin_page():
1312
1329
  response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
1313
1330
  return response
1314
1331
 
1315
- @app.get("/workspace")
1316
- async def workspace_page(request: Request):
1317
- require_user(request)
1318
- workspace_path = STATIC_DIR / "workspace.html"
1319
- if not workspace_path.exists():
1320
- raise HTTPException(status_code=404, detail="Workspace OS UI not found.")
1321
- return ui_file_response(workspace_path)
1322
-
1323
-
1324
- @app.get("/onboarding")
1325
- async def onboarding_page(request: Request):
1326
- require_user(request)
1327
- workspace_path = STATIC_DIR / "workspace.html"
1328
- if not workspace_path.exists():
1329
- raise HTTPException(status_code=404, detail="Workspace OS UI not found.")
1330
- return ui_file_response(workspace_path)
1332
+ # /workspace and /onboarding UI pages are served by the workspace router
1333
+ # (latticeai.api.workspace), included below after its dependencies are defined.
1331
1334
 
1332
1335
  @app.get("/status")
1333
1336
  async def status():
@@ -1432,79 +1435,7 @@ class VerifyCloudRequest(BaseModel):
1432
1435
  provider: Optional[str] = None
1433
1436
 
1434
1437
 
1435
- class WorkspaceOnboardingStepRequest(BaseModel):
1436
- step: str
1437
- status: str = "complete"
1438
- data: Dict = {}
1439
- error: str = ""
1440
-
1441
-
1442
- class WorkspaceOnboardingCompleteRequest(BaseModel):
1443
- data: Dict = {}
1444
-
1445
-
1446
- class WorkspaceSnapshotRequest(BaseModel):
1447
- name: str = "Workspace snapshot"
1448
-
1449
-
1450
- class WorkspaceSnapshotCompareRequest(BaseModel):
1451
- before_id: str
1452
- after_id: str
1453
-
1454
-
1455
- class WorkspaceMemoryRequest(BaseModel):
1456
- kind: str
1457
- content: str
1458
- tags: List[str] = []
1459
- memory_id: Optional[str] = None
1460
- metadata: Dict = {}
1461
-
1462
-
1463
- class WorkspaceAgentRunRequest(BaseModel):
1464
- agent_id: str = "agent:executor"
1465
- status: str = "ok"
1466
- input: str = ""
1467
- output: str = ""
1468
- timeline: List[Dict] = []
1469
- relationships: List[str] = []
1470
-
1471
-
1472
- class WorkspaceWorkflowRequest(BaseModel):
1473
- name: str
1474
- steps: List[Dict] = []
1475
- metadata: Dict = {}
1476
-
1477
-
1478
- class WorkspaceWorkflowEventRequest(BaseModel):
1479
- event_type: str
1480
- payload: Dict = {}
1481
-
1482
-
1483
- class WorkspaceComputerMemoryRequest(BaseModel):
1484
- enabled: bool = False
1485
- consent: Dict = {}
1486
- scopes: List[str] = []
1487
-
1488
-
1489
- class WorkspaceComputerActivityRequest(BaseModel):
1490
- activity: Dict = {}
1491
-
1492
-
1493
- class WorkspaceSkillActionRequest(BaseModel):
1494
- skill: str
1495
- plugin: Optional[str] = None
1496
- enabled: Optional[bool] = None
1497
- version: Optional[str] = None
1498
- metadata: Dict = {}
1499
-
1500
-
1501
- class WorkspaceVSCodeRequest(BaseModel):
1502
- action: str
1503
- file_path: Optional[str] = None
1504
- language: Optional[str] = None
1505
- content: str = ""
1506
- selection: str = ""
1507
- prompt: str = ""
1438
+ # Workspace request models moved to latticeai.api.workspace (v1.2.0 modularization).
1508
1439
 
1509
1440
 
1510
1441
  class GardenRequest(BaseModel):
@@ -1708,410 +1639,37 @@ def _workspace_graph():
1708
1639
  return KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None
1709
1640
 
1710
1641
 
1711
- @app.get("/workspace/os")
1712
- async def workspace_os_summary(request: Request):
1713
- require_user(request)
1714
- summary = WORKSPACE_OS.summary()
1715
- summary["graph"] = _graph_stats_safe()
1716
- summary["models"] = _workspace_models_payload()
1717
- return summary
1718
-
1719
-
1720
- @app.get("/workspace/onboarding/status")
1721
- async def workspace_onboarding_status(request: Request):
1722
- require_user(request)
1723
- return WORKSPACE_OS.onboarding_status(load_users(), _graph_stats_safe())
1724
-
1725
-
1726
- @app.post("/workspace/onboarding/step")
1727
- async def workspace_onboarding_step(req: WorkspaceOnboardingStepRequest, request: Request):
1728
- current_user = require_user(request)
1729
- return WORKSPACE_OS.update_onboarding_step(
1730
- req.step,
1731
- status=req.status,
1732
- data=req.data,
1733
- error=req.error,
1734
- user_email=current_user or None,
1735
- )
1736
-
1737
-
1738
- @app.post("/workspace/onboarding/complete")
1739
- async def workspace_onboarding_complete(req: WorkspaceOnboardingCompleteRequest, request: Request):
1740
- current_user = require_user(request)
1741
- append_audit_event("onboarding_complete", user_email=current_user, platform="AI Workspace OS")
1742
- return WORKSPACE_OS.complete_onboarding(req.data, user_email=current_user or None)
1743
-
1744
-
1745
- @app.get("/workspace/onboarding/hardware")
1746
- async def workspace_onboarding_hardware(request: Request):
1747
- require_user(request)
1748
- env = await asyncio.to_thread(scan_environment)
1749
- sysinfo = await local_sysinfo(request)
1750
- payload = {"environment": env, "sysinfo": sysinfo, "scanned_at": datetime.now().isoformat()}
1751
- WORKSPACE_OS.update_onboarding_step("hardware", status="complete", data=payload, user_email=get_current_user(request))
1752
- return payload
1753
-
1754
-
1755
- @app.get("/workspace/onboarding/model-recommendations")
1756
- async def workspace_onboarding_model_recommendations(request: Request):
1757
- require_user(request)
1758
- env = await asyncio.to_thread(scan_environment)
1759
- recommendations = get_recommendations(env)
1760
- payload = {
1761
- "environment": env,
1762
- "recommendations": recommendations,
1763
- "default_local_model": LOCAL_MODEL,
1764
- "default_public_model": PUBLIC_MODEL,
1765
- }
1766
- WORKSPACE_OS.update_onboarding_step("model_recommendation", status="complete", data=payload, user_email=get_current_user(request))
1767
- return payload
1768
-
1769
-
1770
- @app.get("/workspace/traces")
1771
- async def workspace_traces(request: Request, conversation_id: Optional[str] = None, limit: int = 50):
1772
- require_user(request)
1773
- return WORKSPACE_OS.list_traces(conversation_id=conversation_id, limit=limit)
1774
-
1775
-
1776
- @app.get("/workspace/indexing")
1777
- async def workspace_indexing_dashboard(request: Request):
1778
- require_user(request)
1779
- graph = _workspace_graph()
1780
- watcher_status = LOCAL_KG_WATCHER.status() if LOCAL_KG_WATCHER else {"available": False, "active": {}}
1781
- return WORKSPACE_OS.build_indexing_dashboard(graph, watcher_status)
1782
-
1783
-
1784
- @app.post("/workspace/indexing/{source_id}/pause")
1785
- async def workspace_indexing_pause(source_id: str, request: Request):
1786
- require_user(request)
1787
- _require_graph()
1788
- return WORKSPACE_OS.pause_indexing(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
1789
-
1790
-
1791
- @app.post("/workspace/indexing/{source_id}/resume")
1792
- async def workspace_indexing_resume(source_id: str, request: Request):
1793
- require_user(request)
1794
- _require_graph()
1795
- return WORKSPACE_OS.resume_indexing(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
1796
-
1797
-
1798
- @app.post("/workspace/indexing/{source_id}/remove")
1799
- async def workspace_indexing_remove(source_id: str, request: Request):
1800
- require_user(request)
1801
- _require_graph()
1802
- return WORKSPACE_OS.remove_index_source(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
1803
-
1804
-
1805
- @app.get("/workspace/snapshots")
1806
- async def workspace_snapshots(request: Request):
1807
- require_user(request)
1808
- return WORKSPACE_OS.list_snapshots()
1809
-
1810
-
1811
- @app.post("/workspace/snapshots")
1812
- async def workspace_snapshot_create(req: WorkspaceSnapshotRequest, request: Request):
1813
- current_user = require_user(request)
1814
- result = WORKSPACE_OS.create_snapshot(
1815
- name=req.name,
1816
- graph=_workspace_graph(),
1817
- history=get_history(),
1818
- settings=_workspace_settings_payload(),
1819
- models=_workspace_models_payload(),
1820
- )
1821
- append_audit_event("workspace_snapshot", user_email=current_user, snapshot_id=result["snapshot"]["id"])
1822
- return result
1823
-
1824
-
1825
- @app.post("/workspace/snapshots/compare")
1826
- async def workspace_snapshot_compare(req: WorkspaceSnapshotCompareRequest, request: Request):
1827
- require_user(request)
1828
- try:
1829
- return WORKSPACE_OS.compare_snapshots(req.before_id, req.after_id)
1830
- except FileNotFoundError as exc:
1831
- raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1832
-
1833
-
1834
- @app.get("/workspace/snapshots/{snapshot_id}")
1835
- async def workspace_snapshot_get(snapshot_id: str, request: Request):
1836
- require_user(request)
1837
- try:
1838
- return WORKSPACE_OS.get_snapshot(snapshot_id)
1839
- except FileNotFoundError as exc:
1840
- raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1841
-
1842
-
1843
- @app.get("/workspace/snapshots/{snapshot_id}/{area}")
1844
- async def workspace_snapshot_area(snapshot_id: str, area: str, request: Request):
1845
- require_user(request)
1846
- try:
1847
- return WORKSPACE_OS.snapshot_view(snapshot_id, area)
1848
- except FileNotFoundError as exc:
1849
- raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1850
-
1851
-
1852
- @app.post("/workspace/snapshots/{snapshot_id}/export")
1853
- async def workspace_snapshot_export(snapshot_id: str, request: Request):
1854
- current_user = require_user(request)
1855
- try:
1856
- result = WORKSPACE_OS.export_snapshot(snapshot_id)
1857
- except FileNotFoundError as exc:
1858
- raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1859
- append_audit_event("workspace_snapshot_export", user_email=current_user, snapshot_id=snapshot_id, path=result.get("export_path"))
1860
- return result
1861
-
1862
-
1863
- @app.get("/workspace/time-machine")
1864
- async def workspace_time_machine(request: Request, limit: int = 100):
1865
- require_user(request)
1866
- return WORKSPACE_OS.timeline(get_audit_log(), limit=limit)
1867
-
1868
-
1869
- @app.get("/workspace/time-machine/{snapshot_id}/{area}")
1870
- async def workspace_time_machine_view(snapshot_id: str, area: str, request: Request):
1871
- require_user(request)
1872
- try:
1873
- return WORKSPACE_OS.snapshot_view(snapshot_id, area)
1874
- except FileNotFoundError as exc:
1875
- raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1876
-
1877
-
1878
- @app.get("/workspace/memories")
1879
- async def workspace_memories(request: Request, kind: Optional[str] = None):
1880
- current_user = require_user(request)
1881
- return WORKSPACE_OS.list_memories(user_email=current_user or None, kind=kind)
1882
-
1883
-
1884
- @app.get("/workspace/memories/search")
1885
- async def workspace_memory_search(q: str, request: Request, limit: int = 20):
1886
- current_user = require_user(request)
1887
- return WORKSPACE_OS.search_memories(q, user_email=current_user or None, limit=limit)
1888
-
1889
-
1890
- @app.post("/workspace/memories")
1891
- async def workspace_memory_upsert(req: WorkspaceMemoryRequest, request: Request):
1892
- current_user = require_user(request)
1893
- try:
1894
- record = WORKSPACE_OS.upsert_memory(
1895
- kind=req.kind,
1896
- content=req.content,
1897
- tags=req.tags,
1898
- memory_id=req.memory_id,
1899
- metadata=req.metadata,
1900
- user_email=current_user or None,
1901
- graph=_workspace_graph(),
1902
- )
1903
- except ValueError as exc:
1904
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1905
- return {"memory": record}
1906
-
1907
-
1908
- @app.delete("/workspace/memories/{memory_id}")
1909
- async def workspace_memory_delete(memory_id: str, request: Request):
1910
- require_user(request)
1911
- try:
1912
- return WORKSPACE_OS.delete_memory(memory_id)
1913
- except FileNotFoundError as exc:
1914
- raise HTTPException(status_code=404, detail=f"Memory not found: {exc}") from exc
1915
-
1916
-
1917
- @app.get("/workspace/agents")
1918
- async def workspace_agents(request: Request):
1919
- require_user(request)
1920
- return WORKSPACE_OS.list_agents()
1921
-
1922
-
1923
- @app.post("/workspace/agents/runs")
1924
- async def workspace_agent_run(req: WorkspaceAgentRunRequest, request: Request):
1925
- current_user = require_user(request)
1926
- run = WORKSPACE_OS.record_agent_run(
1927
- agent_id=req.agent_id,
1928
- status=req.status,
1929
- input_text=req.input,
1930
- output_text=req.output,
1931
- timeline=req.timeline,
1932
- relationships=req.relationships,
1933
- user_email=current_user or None,
1934
- graph=_workspace_graph(),
1935
- )
1936
- return {"run": run}
1937
-
1938
-
1939
- @app.get("/workspace/relationships/{node_id:path}")
1940
- async def workspace_relationships(node_id: str, request: Request, target_id: Optional[str] = None):
1941
- require_user(request)
1942
- _require_graph()
1943
- return WORKSPACE_OS.relationship_explorer(KNOWLEDGE_GRAPH, node_id, target_id=target_id)
1944
-
1945
-
1946
- @app.get("/workspace/computer-memory")
1947
- async def workspace_computer_memory(request: Request):
1948
- require_user(request)
1949
- return WORKSPACE_OS.load_state().get("computer_memory")
1950
-
1951
-
1952
- @app.post("/workspace/computer-memory")
1953
- async def workspace_computer_memory_config(req: WorkspaceComputerMemoryRequest, request: Request):
1954
- current_user = require_user(request)
1955
- try:
1956
- config = WORKSPACE_OS.configure_computer_memory(
1957
- enabled=req.enabled,
1958
- approved_by=current_user or None,
1959
- consent=req.consent,
1960
- scopes=req.scopes or None,
1961
- )
1962
- except PermissionError as exc:
1963
- raise HTTPException(status_code=403, detail=str(exc)) from exc
1964
- append_audit_event("computer_memory_config", user_email=current_user, enabled=req.enabled)
1965
- return {"computer_memory": config}
1966
-
1967
-
1968
- @app.post("/workspace/computer-memory/activity")
1969
- async def workspace_computer_memory_activity(req: WorkspaceComputerActivityRequest, request: Request):
1970
- require_user(request)
1971
- return WORKSPACE_OS.record_computer_activity(req.activity, graph=_workspace_graph())
1972
-
1973
-
1974
- @app.get("/workspace/workflows")
1975
- async def workspace_workflows(request: Request, q: str = ""):
1976
- require_user(request)
1977
- return WORKSPACE_OS.list_workflows(query=q)
1978
-
1979
-
1980
- @app.post("/workspace/workflows")
1981
- async def workspace_workflow_create(req: WorkspaceWorkflowRequest, request: Request):
1982
- current_user = require_user(request)
1983
- workflow = WORKSPACE_OS.create_workflow(
1984
- name=req.name,
1985
- steps=req.steps,
1986
- metadata=req.metadata,
1987
- user_email=current_user or None,
1988
- graph=_workspace_graph(),
1989
- )
1990
- return {"workflow": workflow}
1991
-
1992
-
1993
- @app.post("/workspace/workflows/{workflow_id}/events")
1994
- async def workspace_workflow_event(workflow_id: str, req: WorkspaceWorkflowEventRequest, request: Request):
1995
- require_user(request)
1996
- try:
1997
- return {"workflow": WORKSPACE_OS.record_workflow_event(workflow_id, req.event_type, req.payload)}
1998
- except FileNotFoundError as exc:
1999
- raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
2000
-
2001
-
2002
- @app.get("/workspace/skills")
2003
- async def workspace_skills(request: Request):
2004
- require_user(request)
2005
- marketplace = []
2006
- try:
2007
- marketplace = await _fetch_skills_marketplace()
2008
- except Exception as exc:
2009
- logging.warning("workspace skills marketplace unavailable: %s", exc)
2010
- return WORKSPACE_OS.list_skill_registry(SKILLS_DIR, marketplace)
2011
-
2012
-
2013
- @app.post("/workspace/skills/install")
2014
- async def workspace_skill_install(req: WorkspaceSkillActionRequest, request: Request):
2015
- admin_email, _ = require_admin(request)
2016
- if req.plugin:
2017
- result = await install_skill(req.plugin, req.skill)
2018
- else:
2019
- result = {"status": "recorded", "skill": req.skill}
2020
- entry = WORKSPACE_OS.mark_skill_installed(req.skill, version=req.version or "local", metadata={"install_result": result, **req.metadata})
2021
- append_audit_event("skill_install", user_email=admin_email, plugin=req.plugin, skill=req.skill, workspace_os=True)
2022
- return {"skill": entry, "install": result}
2023
-
2024
-
2025
- @app.post("/workspace/skills/uninstall")
2026
- async def workspace_skill_uninstall(req: WorkspaceSkillActionRequest, request: Request):
2027
- admin_email, _ = require_admin(request)
2028
- removal = remove_skill_directory(SKILLS_DIR, req.skill)
2029
- entry = WORKSPACE_OS.mark_skill_uninstalled(req.skill)
2030
- append_audit_event("skill_uninstall", user_email=admin_email, skill=req.skill, workspace_os=True)
2031
- return {"skill": entry, "removal": removal}
2032
-
2033
-
2034
- @app.post("/workspace/skills/enable")
2035
- async def workspace_skill_enable(req: WorkspaceSkillActionRequest, request: Request):
2036
- require_user(request)
2037
- return {"skill": WORKSPACE_OS.set_skill_enabled(req.skill, True)}
2038
-
2039
-
2040
- @app.post("/workspace/skills/disable")
2041
- async def workspace_skill_disable(req: WorkspaceSkillActionRequest, request: Request):
2042
- require_user(request)
2043
- return {"skill": WORKSPACE_OS.set_skill_enabled(req.skill, False)}
2044
-
2045
-
2046
- @app.post("/workspace/skills/update")
2047
- async def workspace_skill_update(req: WorkspaceSkillActionRequest, request: Request):
2048
- admin_email, _ = require_admin(request)
2049
- if req.plugin:
2050
- result = await install_skill(req.plugin, req.skill)
2051
- else:
2052
- result = {"status": "version_recorded", "skill": req.skill}
2053
- entry = WORKSPACE_OS.mark_skill_installed(req.skill, version=req.version or "latest", metadata={"update_result": result, **req.metadata})
2054
- append_audit_event("skill_update", user_email=admin_email, plugin=req.plugin, skill=req.skill, workspace_os=True)
2055
- return {"skill": entry, "update": result}
2056
-
2057
-
2058
- @app.get("/workspace/audit-timeline")
2059
- async def workspace_audit_timeline(
2060
- request: Request,
2061
- user: Optional[str] = None,
2062
- event_type: Optional[str] = None,
2063
- model: Optional[str] = None,
2064
- since: Optional[str] = None,
2065
- until: Optional[str] = None,
2066
- limit: int = 100,
2067
- ):
2068
- require_admin(request)
2069
- return WORKSPACE_OS.filter_audit_timeline(
2070
- get_audit_log(),
2071
- user=user,
2072
- event_type=event_type,
2073
- model=model,
2074
- since=since,
2075
- until=until,
2076
- limit=limit,
2077
- )
2078
-
2079
-
2080
- @app.post("/workspace/vscode/send")
2081
- async def workspace_vscode_send(req: WorkspaceVSCodeRequest, request: Request):
2082
- current_user = require_user(request)
2083
- content = req.selection or req.content or req.prompt
2084
- workflow = WORKSPACE_OS.create_workflow(
2085
- name=f"VS Code: {req.action}",
2086
- steps=[
2087
- {"action": req.action, "file_path": req.file_path, "language": req.language},
2088
- {"action": "send_to_lattice", "chars": len(content or "")},
2089
- ],
2090
- metadata={
2091
- "file_path": req.file_path,
2092
- "language": req.language,
2093
- "content_preview": redact_secret_text(content or "")[:500],
2094
- },
2095
- user_email=current_user or None,
2096
- graph=_workspace_graph(),
2097
- )
2098
- if _workspace_graph() is not None and content:
2099
- try:
2100
- _workspace_graph().ingest_event(
2101
- "VSCodeWorkflow",
2102
- req.action,
2103
- user_email=current_user or None,
2104
- source="vscode",
2105
- metadata={
2106
- "file_path": req.file_path,
2107
- "language": req.language,
2108
- "chars": len(content),
2109
- "workflow_id": workflow["id"],
2110
- },
2111
- )
2112
- except Exception as exc:
2113
- logging.warning("vscode workflow graph ingest failed: %s", exc)
2114
- return {"status": "ok", "workflow": workflow}
1642
+ # ── Workspace OS + Organization router (latticeai.api.workspace, v1.2.0) ──────
1643
+ app.include_router(create_workspace_router(
1644
+ service=WORKSPACE_SERVICE,
1645
+ require_user=require_user,
1646
+ require_admin=require_admin,
1647
+ get_current_user=get_current_user,
1648
+ append_audit_event=append_audit_event,
1649
+ graph_stats=_graph_stats_safe,
1650
+ workspace_models=_workspace_models_payload,
1651
+ workspace_settings=_workspace_settings_payload,
1652
+ get_history=get_history,
1653
+ get_audit_log=get_audit_log,
1654
+ require_graph=_require_graph,
1655
+ workspace_graph=_workspace_graph,
1656
+ knowledge_graph=KNOWLEDGE_GRAPH,
1657
+ local_kg_watcher=LOCAL_KG_WATCHER,
1658
+ load_users=load_users,
1659
+ scan_environment=scan_environment,
1660
+ local_sysinfo=local_sysinfo,
1661
+ get_recommendations=get_recommendations,
1662
+ fetch_skills_marketplace=_fetch_skills_marketplace,
1663
+ install_skill=install_skill,
1664
+ remove_skill_directory=remove_skill_directory,
1665
+ redact_secret_text=redact_secret_text,
1666
+ skills_dir=SKILLS_DIR,
1667
+ capability_registry=capability_registry,
1668
+ ui_file_response=ui_file_response,
1669
+ static_dir=STATIC_DIR,
1670
+ local_model=LOCAL_MODEL,
1671
+ public_model=PUBLIC_MODEL,
1672
+ ))
2115
1673
 
2116
1674
 
2117
1675
  # ── Health & Info ──────────────────────────────────────────────────────────────
@@ -4002,37 +3560,22 @@ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str
4002
3560
  results[model_ref] = record
4003
3561
  return results
4004
3562
 
4005
- @app.get("/health")
4006
- async def health(request: Request):
4007
- base = {
4008
- "status": "ok",
4009
- "version": APP_VERSION,
4010
- "mode": APP_MODE,
4011
- "platform": "AI Workspace OS",
4012
- }
4013
- if not get_current_user(request) and REQUIRE_AUTH:
4014
- return base
4015
- engines = await asyncio.to_thread(engine_status)
4016
- return {
4017
- **base,
4018
- "current_model": router.current_model_id,
4019
- "loaded_models": router.loaded_model_ids,
4020
- "device": "Apple Silicon MLX" if not IS_PUBLIC_MODE else "Public cloud/API runtime",
4021
- "features": runtime_features(),
4022
- "providers": router.detected_cloud_models(),
4023
- "engines": engines,
4024
- }
4025
-
4026
-
4027
- @app.get("/mode")
4028
- @app.get("/runtime_features")
4029
- async def mode():
4030
- return runtime_features()
4031
-
4032
-
4033
- @app.get("/engines")
4034
- async def engines():
4035
- return {"engines": await asyncio.to_thread(engine_status), "current": router.current_model_id}
3563
+ # ── Health / status / engine-summary router (latticeai.api.health, v1.2.0) ───
3564
+ # /health, /mode, /runtime_features, /engines(GET) now live in the health router.
3565
+ # Heavier engine mutation endpoints remain below in server_app.
3566
+ MODEL_SERVICE = ModelService(
3567
+ model_router=router,
3568
+ runtime_features=runtime_features,
3569
+ is_public=IS_PUBLIC_MODE,
3570
+ )
3571
+ app.include_router(create_health_router(
3572
+ model_service=MODEL_SERVICE,
3573
+ engine_status=engine_status,
3574
+ get_current_user=get_current_user,
3575
+ require_auth=REQUIRE_AUTH,
3576
+ app_version=APP_VERSION,
3577
+ app_mode=APP_MODE,
3578
+ ))
4036
3579
 
4037
3580
 
4038
3581
  @app.post("/engines/install")
@@ -4435,7 +3978,7 @@ async def chat(req: ChatRequest, request: Request):
4435
3978
  except Exception:
4436
3979
  pass
4437
3980
 
4438
- trace_seed = WORKSPACE_OS.build_graph_trace(
3981
+ trace_seed = CHAT_SERVICE.build_graph_trace(
4439
3982
  req.message,
4440
3983
  KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
4441
3984
  context,
@@ -4473,7 +4016,7 @@ async def chat(req: ChatRequest, request: Request):
4473
4016
  full_text += footnote
4474
4017
  session.update(graph_md, full_text, req.conversation_id)
4475
4018
  save_to_history("assistant", full_text, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4476
- trace_record = WORKSPACE_OS.record_trace(
4019
+ trace_record = CHAT_SERVICE.record_trace(
4477
4020
  question=req.message,
4478
4021
  response=full_text,
4479
4022
  conversation_id=req.conversation_id,
@@ -4499,7 +4042,7 @@ async def chat(req: ChatRequest, request: Request):
4499
4042
  result += footnote
4500
4043
  session.update(graph_md, result, req.conversation_id)
4501
4044
  save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4502
- trace_record = WORKSPACE_OS.record_trace(
4045
+ trace_record = CHAT_SERVICE.record_trace(
4503
4046
  question=req.message,
4504
4047
  response=str(result),
4505
4048
  conversation_id=req.conversation_id,
@@ -4536,7 +4079,7 @@ async def chat(req: ChatRequest, request: Request):
4536
4079
  result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
4537
4080
 
4538
4081
  save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4539
- trace_record = WORKSPACE_OS.record_trace(
4082
+ trace_record = CHAT_SERVICE.record_trace(
4540
4083
  question=req.message,
4541
4084
  response=str(result),
4542
4085
  conversation_id=req.conversation_id,
@@ -4640,12 +4183,12 @@ async def _stream_chat(
4640
4183
  yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
4641
4184
  history_user = get_history_user(req.user_email, req.user_nickname)
4642
4185
  save_to_history("assistant", full_response, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4643
- trace_record = WORKSPACE_OS.record_trace(
4186
+ trace_record = CHAT_SERVICE.record_trace(
4644
4187
  question=req.message,
4645
4188
  response=full_response,
4646
4189
  conversation_id=req.conversation_id,
4647
4190
  user_email=effective_email or req.user_email,
4648
- trace=trace_seed or WORKSPACE_OS.build_graph_trace(
4191
+ trace=trace_seed or CHAT_SERVICE.build_graph_trace(
4649
4192
  req.message,
4650
4193
  KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
4651
4194
  context,