ltcai 3.6.0 → 4.0.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.
Files changed (169) hide show
  1. package/README.md +11 -7
  2. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  3. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
  4. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  5. package/docs/kg-schema.md +47 -53
  6. package/kg_schema.py +93 -10
  7. package/knowledge_graph.py +362 -33
  8. package/knowledge_graph_api.py +11 -127
  9. package/latticeai/__init__.py +1 -1
  10. package/latticeai/api/admin.py +1 -1
  11. package/latticeai/api/agents.py +7 -1
  12. package/latticeai/api/auth.py +27 -4
  13. package/latticeai/api/chat.py +112 -76
  14. package/latticeai/api/health.py +1 -1
  15. package/latticeai/api/hooks.py +1 -1
  16. package/latticeai/api/knowledge_graph.py +146 -0
  17. package/latticeai/api/local_files.py +1 -1
  18. package/latticeai/api/mcp.py +23 -11
  19. package/latticeai/api/memory.py +1 -1
  20. package/latticeai/api/models.py +1 -1
  21. package/latticeai/api/network.py +81 -0
  22. package/latticeai/api/realtime.py +1 -1
  23. package/latticeai/api/search.py +26 -2
  24. package/latticeai/api/security_dashboard.py +2 -3
  25. package/latticeai/api/setup.py +2 -2
  26. package/latticeai/api/static_routes.py +2 -4
  27. package/latticeai/api/tools.py +3 -0
  28. package/latticeai/api/workflow_designer.py +46 -0
  29. package/latticeai/api/workspace.py +71 -49
  30. package/latticeai/app_factory.py +1710 -0
  31. package/latticeai/brain/__init__.py +18 -0
  32. package/latticeai/brain/context.py +213 -0
  33. package/latticeai/brain/conversations.py +236 -0
  34. package/latticeai/brain/identity.py +175 -0
  35. package/latticeai/brain/memory.py +102 -0
  36. package/latticeai/brain/network.py +205 -0
  37. package/latticeai/core/agent.py +31 -7
  38. package/latticeai/core/audit.py +0 -7
  39. package/latticeai/core/config.py +1 -1
  40. package/latticeai/core/context_builder.py +1 -2
  41. package/latticeai/core/enterprise.py +1 -1
  42. package/latticeai/core/graph_curator.py +2 -2
  43. package/latticeai/core/marketplace.py +1 -1
  44. package/latticeai/core/mcp_registry.py +791 -0
  45. package/latticeai/core/model_compat.py +1 -1
  46. package/latticeai/core/model_resolution.py +0 -1
  47. package/latticeai/core/multi_agent.py +238 -4
  48. package/latticeai/core/security.py +1 -1
  49. package/latticeai/core/sessions.py +37 -7
  50. package/latticeai/core/workflow_engine.py +114 -2
  51. package/latticeai/core/workspace_os.py +58 -10
  52. package/latticeai/models/__init__.py +7 -0
  53. package/latticeai/models/router.py +779 -0
  54. package/latticeai/server_app.py +29 -1536
  55. package/latticeai/services/agent_runtime.py +1 -0
  56. package/latticeai/services/app_context.py +75 -14
  57. package/latticeai/services/ingestion.py +47 -0
  58. package/latticeai/services/kg_portability.py +33 -3
  59. package/latticeai/services/memory_service.py +39 -11
  60. package/latticeai/services/model_runtime.py +2 -5
  61. package/latticeai/services/platform_runtime.py +100 -23
  62. package/latticeai/services/search_service.py +17 -8
  63. package/latticeai/services/tool_dispatch.py +12 -2
  64. package/latticeai/services/triggers.py +241 -0
  65. package/latticeai/services/upload_service.py +37 -12
  66. package/latticeai/services/workspace_service.py +31 -0
  67. package/llm_router.py +29 -772
  68. package/ltcai_cli.py +1 -2
  69. package/mcp_registry.py +25 -788
  70. package/p_reinforce.py +124 -14
  71. package/package.json +9 -7
  72. package/scripts/bump_version.py +99 -0
  73. package/scripts/generate_diagrams.py +0 -1
  74. package/scripts/lint_v3.mjs +82 -18
  75. package/scripts/validate_release_artifacts.py +0 -1
  76. package/scripts/wheel_smoke.py +142 -0
  77. package/server.py +11 -7
  78. package/setup_wizard.py +1142 -0
  79. package/static/account.html +2 -4
  80. package/static/admin.html +3 -5
  81. package/static/chat.html +3 -6
  82. package/static/graph.html +2 -4
  83. package/static/sw.js +81 -52
  84. package/static/v3/asset-manifest.json +20 -19
  85. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  86. package/static/v3/css/lattice.base.css +1 -1
  87. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  88. package/static/v3/css/lattice.components.css +1 -1
  89. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  90. package/static/v3/css/lattice.shell.css +1 -1
  91. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  92. package/static/v3/css/lattice.tokens.css +3 -0
  93. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  94. package/static/v3/css/lattice.views.css +2 -2
  95. package/static/v3/index.html +3 -4
  96. package/static/v3/js/{app.c541f955.js → app.356e6452.js} +1 -1
  97. package/static/v3/js/core/{api.33d6320e.js → api.7a308b89.js} +1 -1
  98. package/static/v3/js/core/{routes.2ce3815a.js → routes.7222343d.js} +22 -22
  99. package/static/v3/js/core/routes.js +22 -22
  100. package/static/v3/js/core/{shell.8c163e0e.js → shell.a1657f20.js} +4 -4
  101. package/static/v3/js/core/shell.js +1 -1
  102. package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
  103. package/static/v3/js/core/store.js +1 -1
  104. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  105. package/static/v3/js/views/graph-canvas.js +509 -0
  106. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  107. package/static/v3/js/views/hybrid-search.js +1 -2
  108. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.5e40cbeb.js} +33 -37
  109. package/static/v3/js/views/knowledge-graph.js +33 -37
  110. package/static/vendor/chart.umd.min.js +20 -0
  111. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  112. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  113. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  114. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  115. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  116. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  117. package/static/vendor/fonts/inter.css +44 -0
  118. package/static/vendor/icons/tabler-icons.min.css +4 -0
  119. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  120. package/static/vendor/marked.min.js +69 -0
  121. package/static/workspace.html +2 -2
  122. package/telegram_bot.py +1 -2
  123. package/tools/commands.py +4 -2
  124. package/tools/computer.py +1 -1
  125. package/tools/documents.py +1 -3
  126. package/tools/filesystem.py +0 -4
  127. package/tools/knowledge.py +1 -3
  128. package/tools/network.py +1 -3
  129. package/codex_telegram_bot.py +0 -195
  130. package/docs/assets/v3.4.0/agent-run.png +0 -0
  131. package/docs/assets/v3.4.0/agents.png +0 -0
  132. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  133. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  134. package/docs/assets/v3.4.0/chat.png +0 -0
  135. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  136. package/docs/assets/v3.4.0/files.png +0 -0
  137. package/docs/assets/v3.4.0/home.png +0 -0
  138. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  139. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  140. package/docs/assets/v3.4.0/local-agent.png +0 -0
  141. package/docs/assets/v3.4.0/memory.png +0 -0
  142. package/docs/assets/v3.4.0/settings.png +0 -0
  143. package/docs/assets/v3.4.0/vision-input.png +0 -0
  144. package/docs/assets/v3.4.0/workflows.png +0 -0
  145. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  146. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  147. package/docs/assets/v3.4.1/local-agent.png +0 -0
  148. package/docs/images/admin-dashboard.png +0 -0
  149. package/docs/images/architecture.png +0 -0
  150. package/docs/images/enterprise.png +0 -0
  151. package/docs/images/graph.png +0 -0
  152. package/docs/images/hero.gif +0 -0
  153. package/docs/images/knowledge-graph.png +0 -0
  154. package/docs/images/lattice-ai-demo.gif +0 -0
  155. package/docs/images/lattice-ai-hero.png +0 -0
  156. package/docs/images/logo.svg +0 -33
  157. package/docs/images/mobile-responsive.png +0 -0
  158. package/docs/images/model-recommendation.png +0 -0
  159. package/docs/images/onboarding.png +0 -0
  160. package/docs/images/organization.png +0 -0
  161. package/docs/images/pipeline.png +0 -0
  162. package/docs/images/screenshot-admin.png +0 -0
  163. package/docs/images/screenshot-chat.png +0 -0
  164. package/docs/images/screenshot-graph.png +0 -0
  165. package/docs/images/skills.png +0 -0
  166. package/docs/images/workspace-dark.png +0 -0
  167. package/docs/images/workspace-light.png +0 -0
  168. package/docs/images/workspace.png +0 -0
  169. package/requirements.txt +0 -16
@@ -264,7 +264,7 @@ def create_models_router(
264
264
 
265
265
  @router.post("/setup/set-api-key")
266
266
  async def set_api_key(req: SetApiKeyRequest, request: Request):
267
- from llm_router import OPENAI_COMPATIBLE_PROVIDERS
267
+ from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS
268
268
  config = OPENAI_COMPATIBLE_PROVIDERS.get(req.provider)
269
269
  if not config:
270
270
  raise HTTPException(status_code=400, detail="알 수 없는 프로바이더입니다.")
@@ -0,0 +1,81 @@
1
+ """Brain Network API — device identity, peer pairing, knowledge exchange.
2
+
3
+ The /network/receive endpoint authenticates PEERS (signed device requests),
4
+ not user sessions; everything else requires a logged-in user, and pairing /
5
+ pushing are deliberate owner actions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from fastapi import APIRouter, HTTPException, Request
13
+ from pydantic import BaseModel
14
+
15
+
16
+ class PeerPairRequest(BaseModel):
17
+ name: str
18
+ base_url: str
19
+ public_key: str
20
+
21
+
22
+ class PeerPushRequest(BaseModel):
23
+ workspace_id: Optional[str] = None
24
+
25
+
26
+ def create_network_router(*, network, identity, require_user) -> APIRouter:
27
+ router = APIRouter()
28
+
29
+ @router.get("/network/identity")
30
+ async def network_identity(request: Request):
31
+ require_user(request)
32
+ return identity.describe()
33
+
34
+ @router.get("/network/peers")
35
+ async def network_peers(request: Request):
36
+ require_user(request)
37
+ return {"peers": network.list_peers()}
38
+
39
+ @router.post("/network/peers")
40
+ async def network_pair(req: PeerPairRequest, request: Request):
41
+ require_user(request)
42
+ try:
43
+ return {"status": "paired", "peer": network.add_peer(
44
+ name=req.name, base_url=req.base_url, public_key=req.public_key,
45
+ )}
46
+ except ValueError as exc:
47
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
48
+
49
+ @router.delete("/network/peers/{peer_id}")
50
+ async def network_unpair(peer_id: str, request: Request):
51
+ require_user(request)
52
+ try:
53
+ return network.remove_peer(peer_id)
54
+ except FileNotFoundError as exc:
55
+ raise HTTPException(status_code=404, detail=f"Unknown peer: {exc}") from exc
56
+
57
+ @router.post("/network/push/{peer_id}")
58
+ async def network_push(peer_id: str, req: PeerPushRequest, request: Request):
59
+ require_user(request)
60
+ try:
61
+ return network.push_to_peer(peer_id, workspace_id=req.workspace_id)
62
+ except FileNotFoundError as exc:
63
+ raise HTTPException(status_code=404, detail=f"Unknown peer: {exc}") from exc
64
+ except Exception as exc:
65
+ raise HTTPException(status_code=502, detail=f"Push failed: {exc}") from exc
66
+
67
+ @router.post("/network/receive")
68
+ async def network_receive(request: Request):
69
+ # Peer-authenticated: a paired device's signature replaces the session.
70
+ body = await request.body()
71
+ try:
72
+ return network.receive(dict(request.headers), body)
73
+ except PermissionError as exc:
74
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
75
+ except ValueError as exc:
76
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
77
+
78
+ return router
79
+
80
+
81
+ __all__ = ["create_network_router"]
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import secrets
12
12
  from pathlib import Path
13
- from typing import Any, Callable, Dict, Optional, Set
13
+ from typing import Any, Callable, Optional, Set
14
14
 
15
15
  from fastapi import APIRouter, HTTPException, Request
16
16
  from fastapi.responses import StreamingResponse
@@ -47,17 +47,41 @@ class IndexRebuildRequest(BaseModel):
47
47
  include_chunks: bool = True
48
48
 
49
49
 
50
+ class _ScopedSearchService:
51
+ """Injects the caller's workspace scope into every search call —
52
+ enforcement lives at this one chokepoint, not in each handler."""
53
+
54
+ _SCOPED = {"keyword_search", "vector_search", "graph_search", "hybrid_search"}
55
+
56
+ def __init__(self, service: SearchService, allowed):
57
+ self._service = service
58
+ self._allowed = allowed
59
+
60
+ def __getattr__(self, name):
61
+ attr = getattr(self._service, name)
62
+ if name in self._SCOPED:
63
+ def scoped(*args, **kwargs):
64
+ kwargs.setdefault("allowed_workspaces", self._allowed)
65
+ return attr(*args, **kwargs)
66
+ return scoped
67
+ return attr
68
+
69
+
50
70
  def create_search_router(
51
71
  *,
52
72
  service: SearchService,
53
73
  require_user: Callable[[Request], str],
54
74
  embedding_info: Optional[Callable[[], Dict[str, Any]]] = None,
75
+ allowed_workspaces_for: Optional[Callable[[Optional[str]], Any]] = None,
55
76
  ) -> APIRouter:
56
77
  router = APIRouter()
57
78
 
58
79
  def _guarded(request: Request) -> SearchService:
59
- require_user(request)
60
- return service
80
+ user = require_user(request)
81
+ allowed = None
82
+ if allowed_workspaces_for is not None and user:
83
+ allowed = allowed_workspaces_for(user)
84
+ return _ScopedSearchService(service, allowed)
61
85
 
62
86
  def _raise_graph_error(exc: Exception) -> None:
63
87
  raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -27,13 +27,12 @@ import io
27
27
  import json
28
28
  import logging
29
29
  import re
30
- import time
31
30
  from collections import defaultdict
32
31
  from datetime import datetime
33
- from typing import Any, Callable, Dict, List, Optional, Tuple
32
+ from typing import Any, Callable, Dict, List, Optional
34
33
 
35
34
  from fastapi import APIRouter, HTTPException, Query, Request
36
- from fastapi.responses import Response, StreamingResponse
35
+ from fastapi.responses import Response
37
36
  from pydantic import BaseModel
38
37
 
39
38
  from ..core import timezones
@@ -15,8 +15,8 @@ from auto_setup import (
15
15
  recommend as auto_setup_recommend,
16
16
  verify as auto_setup_verify,
17
17
  )
18
- from llm_router import parse_model_ref
19
- from setup import get_recommendations, install_stream, open_url, scan_environment
18
+ from latticeai.models.router import parse_model_ref
19
+ from setup_wizard import get_recommendations, install_stream, open_url, scan_environment
20
20
 
21
21
 
22
22
  def create_setup_router(*, model_router, require_user) -> APIRouter:
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import re
6
5
  import subprocess
7
6
  from dataclasses import dataclass
8
7
  from pathlib import Path
@@ -58,7 +57,7 @@ def create_static_routes_router(
58
57
  return response
59
58
 
60
59
  # 3. 인증 실패 시 차단 화면
61
- return HTMLResponse(content=f"""
60
+ return HTMLResponse(content="""
62
61
  <body style="background:#0f1115; color:white; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:sans-serif;">
63
62
  <div style="background:#16191f; padding:40px; border-radius:24px; border:1px solid rgba(255,255,255,0.1); text-align:center; box-shadow: 0 20px 40px rgba(0,0,0,0.5);">
64
63
  <div style="font-size:48px; margin-bottom:20px;">🔒</div>
@@ -145,7 +144,7 @@ def create_static_routes_router(
145
144
  async def local_sysinfo(request: Request):
146
145
  """CPU / RAM / GPU(MLX) 사용량을 반환합니다."""
147
146
  require_user(request)
148
- import subprocess, re as _re
147
+ import re as _re
149
148
  result = {"cpu_pct": 0.0, "ram_pct": 0.0, "gpu_mem_pct": 0.0, "gpu_mem_gb": 0.0}
150
149
  try:
151
150
  # CPU
@@ -157,7 +156,6 @@ def create_static_routes_router(
157
156
  result["cpu_pct"] = round(float(m.group(1)) + float(m.group(2)), 1)
158
157
  # RAM
159
158
  vm_out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=4).stdout
160
- page_size = 16384
161
159
  pages: dict = {}
162
160
  for line in vm_out.splitlines():
163
161
  for key in ["Pages free", "Pages active", "Pages inactive", "Pages wired down", "Pages occupied by compressor"]:
@@ -178,6 +178,7 @@ class ToolGitShowRequest(BaseModel):
178
178
  def create_tools_router(
179
179
  *,
180
180
  config,
181
+ ingestion_pipeline,
181
182
  data_dir: Path,
182
183
  static_dir: Path,
183
184
  model_router,
@@ -438,6 +439,7 @@ def create_tools_router(
438
439
  current_user=current_user,
439
440
  enable_graph=ENABLE_GRAPH,
440
441
  knowledge_graph=KNOWLEDGE_GRAPH,
442
+ ingestion_pipeline=ingestion_pipeline,
441
443
  bytes_match_extension=_bytes_match_extension,
442
444
  classify_sensitive_message=classify_sensitive_message,
443
445
  append_audit_event=append_audit_event,
@@ -588,6 +590,7 @@ def create_tools_router(
588
590
  tool_response=_tool_response,
589
591
  require_graph=_require_graph,
590
592
  knowledge_graph=KNOWLEDGE_GRAPH,
593
+ ingestion_pipeline=ingestion_pipeline,
591
594
  data_dir=DATA_DIR,
592
595
  ))
593
596
 
@@ -32,6 +32,10 @@ class WorkflowUpdateRequest(BaseModel):
32
32
  metadata: Optional[Dict[str, Any]] = None
33
33
 
34
34
 
35
+ class WorkflowResumeRequest(BaseModel):
36
+ approved: bool = True
37
+
38
+
35
39
  class WorkflowRunRequest(BaseModel):
36
40
  inputs: Dict[str, Any] = {}
37
41
 
@@ -159,10 +163,52 @@ def create_workflow_designer_router(
159
163
  user_email=current_user or None,
160
164
  graph=workspace_graph(),
161
165
  workspace_id=scope,
166
+ mode="live",
167
+ pause={"node": result.paused_node, "pending": result.pending_approval,
168
+ "context": result.paused_context} if result.status == "awaiting_approval" else None,
162
169
  )
163
170
  append_audit_event("workflow_run", user_email=current_user, workflow_id=workflow_id, status=result.status)
164
171
  return {"run": run, "result": result.as_dict()}
165
172
 
173
+ @router.post("/workflows/api/runs/{run_id}/resume")
174
+ async def resume_run(run_id: str, req: WorkflowResumeRequest, request: Request):
175
+ """Decide a paused (awaiting_approval) run: approve → the paused node
176
+ executes and the run continues; deny → the run fails honestly."""
177
+ current_user = require_user(request)
178
+ scope = gate_write(request)
179
+ run_record = store.get_workflow_run(run_id, workspace_id=scope)
180
+ pause = run_record.get("pause") or {}
181
+ if run_record.get("status") != "awaiting_approval" or not pause.get("node"):
182
+ raise HTTPException(status_code=409, detail="run is not awaiting approval")
183
+ workflow = store.get_workflow(run_record.get("workflow_id"), workspace_id=scope)
184
+ runners = build_runners(current_user or None, scope)
185
+ engine = WorkflowEngine(runners, hooks=hooks)
186
+ result = engine.resume(
187
+ workflow,
188
+ paused_node=pause["node"],
189
+ paused_context=pause.get("context") or {},
190
+ approved=bool(req.approved),
191
+ prior_timeline=run_record.get("timeline") or [],
192
+ )
193
+ resumed = store.record_workflow_run(
194
+ workflow_id=run_record.get("workflow_id"),
195
+ name=run_record.get("name") or "workflow",
196
+ status=result.status,
197
+ timeline=result.timeline,
198
+ outputs=result.outputs,
199
+ user_email=current_user or None,
200
+ graph=workspace_graph(),
201
+ workspace_id=scope,
202
+ mode="live",
203
+ pause={"node": result.paused_node, "pending": result.pending_approval,
204
+ "context": result.paused_context} if result.status == "awaiting_approval" else None,
205
+ )
206
+ store.mark_workflow_run_resolved(run_id, resumed_run_id=resumed["id"],
207
+ approved=bool(req.approved), workspace_id=scope)
208
+ append_audit_event("workflow_run_resume", user_email=current_user,
209
+ run_id=run_id, approved=bool(req.approved), status=result.status)
210
+ return {"run": resumed, "result": result.as_dict(), "resumed_from": run_id}
211
+
166
212
  @router.get("/workflows/api/definitions/{workflow_id}/runs")
167
213
  async def list_runs(workflow_id: str, request: Request, limit: int = 50):
168
214
  require_user(request)
@@ -15,12 +15,13 @@ from __future__ import annotations
15
15
  import asyncio
16
16
  import logging
17
17
  from datetime import datetime
18
- from pathlib import Path
19
- from typing import Any, Callable, Dict, List, Optional
18
+ from typing import Dict, List, Optional
20
19
 
21
20
  from fastapi import APIRouter, HTTPException, Request
22
21
  from pydantic import BaseModel
23
22
 
23
+ from latticeai.services.app_context import AppContext
24
+
24
25
 
25
26
  # ── Request models (workspace-only; moved verbatim from server_app) ──────────
26
27
 
@@ -135,54 +136,47 @@ def _workspace_scope_from_request(request: Request) -> Optional[str]:
135
136
  return query.strip() if query and query.strip() else None
136
137
 
137
138
 
138
- def create_workspace_router(
139
- *,
140
- service,
141
- require_user: Callable[[Request], str],
142
- require_admin: Callable[[Request], Any],
143
- get_current_user: Callable[[Request], Optional[str]],
144
- append_audit_event: Callable[..., None],
145
- graph_stats: Callable[[], Dict],
146
- workspace_models: Callable[[], Dict],
147
- workspace_settings: Callable[[], Dict],
148
- get_history: Callable[[], List[Dict]],
149
- get_audit_log: Callable[[], List[Dict]],
150
- require_graph: Callable[[], Any],
151
- workspace_graph: Callable[[], Any],
152
- knowledge_graph: Any,
153
- local_kg_watcher: Any,
154
- load_users: Callable[[], Dict],
155
- scan_environment: Callable[[], Any],
156
- local_sysinfo: Callable[[Request], Any],
157
- get_recommendations: Callable[[Any], Any],
158
- fetch_skills_marketplace: Callable[..., Any],
159
- install_skill: Callable[..., Any],
160
- remove_skill_directory: Callable[..., Dict],
161
- redact_secret_text: Callable[[str], str],
162
- skills_dir: Path,
163
- capability_registry: Any,
164
- ui_file_response: Callable[[Path], Any],
165
- static_dir: Path,
166
- local_model: Optional[str],
167
- public_model: Optional[str],
168
- ) -> APIRouter:
139
+ def create_workspace_router(context: AppContext) -> APIRouter:
140
+ """Build the workspace/org router from the typed application context.
141
+
142
+ Replaces the historical ~30-kwarg factory signature: ``context``
143
+ (:class:`latticeai.services.app_context.AppContext`) carries the same
144
+ dependencies as typed fields.
145
+ """
169
146
  router = APIRouter()
170
147
 
171
148
  # Bind injected deps to the names the moved handler bodies expect.
149
+ service = context.workspace_service
150
+ require_user = context.require_user
151
+ require_admin = context.require_admin
152
+ get_current_user = context.get_current_user
153
+ append_audit_event = context.append_audit_event
154
+ get_history = context.get_history
155
+ get_audit_log = context.get_audit_log
156
+ load_users = context.load_users
157
+ scan_environment = context.scan_environment
158
+ local_sysinfo = context.local_sysinfo
159
+ get_recommendations = context.get_recommendations
160
+ install_skill = context.install_skill
161
+ remove_skill_directory = context.remove_skill_directory
162
+ redact_secret_text = context.redact_secret_text
163
+ capability_registry = context.capability_registry
164
+ ui_file_response = context.ui_file_response
165
+
172
166
  svc = service
173
167
  WORKSPACE_OS = service.store
174
- _workspace_graph = workspace_graph
175
- _graph_stats_safe = graph_stats
176
- _workspace_models_payload = workspace_models
177
- _workspace_settings_payload = workspace_settings
178
- _require_graph = require_graph
179
- KNOWLEDGE_GRAPH = knowledge_graph
180
- LOCAL_KG_WATCHER = local_kg_watcher
181
- SKILLS_DIR = skills_dir
182
- STATIC_DIR = static_dir
183
- LOCAL_MODEL = local_model
184
- PUBLIC_MODEL = public_model
185
- _fetch_skills_marketplace = fetch_skills_marketplace
168
+ _workspace_graph = context.workspace_graph
169
+ _graph_stats_safe = context.graph_stats
170
+ _workspace_models_payload = context.workspace_models
171
+ _workspace_settings_payload = context.workspace_settings
172
+ _require_graph = context.require_graph
173
+ KNOWLEDGE_GRAPH = context.knowledge_graph
174
+ LOCAL_KG_WATCHER = context.local_kg_watcher
175
+ SKILLS_DIR = context.skills_dir
176
+ STATIC_DIR = context.static_dir
177
+ LOCAL_MODEL = context.local_model
178
+ PUBLIC_MODEL = context.public_model
179
+ _fetch_skills_marketplace = context.fetch_skills_marketplace
186
180
  _workspace_scope = _workspace_scope_from_request
187
181
 
188
182
  def _gate_read(request: Request):
@@ -197,6 +191,24 @@ def create_workspace_router(
197
191
  except PermissionError as exc:
198
192
  raise HTTPException(status_code=403, detail=str(exc)) from exc
199
193
 
194
+ def _load_snapshot_authorized(request: Request, snapshot_id: str) -> dict:
195
+ """Fetch a snapshot and authorize against the RECORD'S own workspace.
196
+
197
+ By-id access must not bypass workspace gating: a snapshot belonging to
198
+ an organization workspace is readable only by its members. Snapshots
199
+ predating workspace scoping carry no workspace_id and stay readable
200
+ (legacy-global compatibility).
201
+ """
202
+ try:
203
+ snapshot = WORKSPACE_OS.get_snapshot(snapshot_id)
204
+ except FileNotFoundError as exc:
205
+ raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
206
+ try:
207
+ svc.authorize_record_read(snapshot, get_current_user(request))
208
+ except PermissionError as exc:
209
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
210
+ return snapshot
211
+
200
212
  # ── Workspace UI pages ────────────────────────────────────────────────
201
213
 
202
214
  @router.get("/workspace")
@@ -343,6 +355,8 @@ def create_workspace_router(
343
355
  @router.post("/workspace/snapshots/compare")
344
356
  async def workspace_snapshot_compare(req: WorkspaceSnapshotCompareRequest, request: Request):
345
357
  require_user(request)
358
+ _load_snapshot_authorized(request, req.before_id)
359
+ _load_snapshot_authorized(request, req.after_id)
346
360
  try:
347
361
  return WORKSPACE_OS.compare_snapshots(req.before_id, req.after_id)
348
362
  except FileNotFoundError as exc:
@@ -351,14 +365,12 @@ def create_workspace_router(
351
365
  @router.get("/workspace/snapshots/{snapshot_id}")
352
366
  async def workspace_snapshot_get(snapshot_id: str, request: Request):
353
367
  require_user(request)
354
- try:
355
- return WORKSPACE_OS.get_snapshot(snapshot_id)
356
- except FileNotFoundError as exc:
357
- raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
368
+ return _load_snapshot_authorized(request, snapshot_id)
358
369
 
359
370
  @router.get("/workspace/snapshots/{snapshot_id}/{area}")
360
371
  async def workspace_snapshot_area(snapshot_id: str, area: str, request: Request):
361
372
  require_user(request)
373
+ _load_snapshot_authorized(request, snapshot_id)
362
374
  try:
363
375
  return WORKSPACE_OS.snapshot_view(snapshot_id, area)
364
376
  except FileNotFoundError as exc:
@@ -367,6 +379,7 @@ def create_workspace_router(
367
379
  @router.post("/workspace/snapshots/{snapshot_id}/export")
368
380
  async def workspace_snapshot_export(snapshot_id: str, request: Request):
369
381
  current_user = require_user(request)
382
+ _load_snapshot_authorized(request, snapshot_id)
370
383
  try:
371
384
  result = WORKSPACE_OS.export_snapshot(snapshot_id)
372
385
  except FileNotFoundError as exc:
@@ -383,6 +396,7 @@ def create_workspace_router(
383
396
  @router.get("/workspace/time-machine/{snapshot_id}/{area}")
384
397
  async def workspace_time_machine_view(snapshot_id: str, area: str, request: Request):
385
398
  require_user(request)
399
+ _load_snapshot_authorized(request, snapshot_id)
386
400
  try:
387
401
  return WORKSPACE_OS.snapshot_view(snapshot_id, area)
388
402
  except FileNotFoundError as exc:
@@ -424,6 +438,14 @@ def create_workspace_router(
424
438
  @router.delete("/workspace/memories/{memory_id}")
425
439
  async def workspace_memory_delete(memory_id: str, request: Request):
426
440
  require_user(request)
441
+ try:
442
+ record = WORKSPACE_OS.get_memory(memory_id)
443
+ except FileNotFoundError as exc:
444
+ raise HTTPException(status_code=404, detail=f"Memory not found: {exc}") from exc
445
+ try:
446
+ svc.authorize_memory_delete(record, get_current_user(request))
447
+ except PermissionError as exc:
448
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
427
449
  try:
428
450
  return WORKSPACE_OS.delete_memory(memory_id)
429
451
  except FileNotFoundError as exc: