ltcai 4.0.0 → 4.0.1

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 (108) hide show
  1. package/README.md +37 -33
  2. package/docs/CHANGELOG.md +64 -0
  3. package/docs/REALTIME_COLLABORATION.md +3 -3
  4. package/docs/V3_FRONTEND.md +9 -8
  5. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +86 -43
  6. package/docs/kg-schema.md +6 -2
  7. package/docs/spec-vs-impl.md +10 -10
  8. package/kg_schema.py +2 -603
  9. package/knowledge_graph.py +37 -4958
  10. package/latticeai/__init__.py +1 -1
  11. package/latticeai/api/admin.py +15 -16
  12. package/latticeai/api/agents.py +13 -6
  13. package/latticeai/api/auth.py +19 -11
  14. package/latticeai/api/invitations.py +100 -0
  15. package/latticeai/api/knowledge_graph.py +4 -11
  16. package/latticeai/api/plugins.py +3 -6
  17. package/latticeai/api/realtime.py +4 -7
  18. package/latticeai/api/static_routes.py +9 -12
  19. package/latticeai/api/ui_redirects.py +26 -0
  20. package/latticeai/api/workflow_designer.py +39 -6
  21. package/latticeai/api/workspace.py +24 -10
  22. package/latticeai/app_factory.py +88 -17
  23. package/latticeai/brain/_kg_common.py +1123 -0
  24. package/latticeai/brain/discovery.py +1455 -0
  25. package/latticeai/brain/documents.py +218 -0
  26. package/latticeai/brain/ingest.py +644 -0
  27. package/latticeai/brain/projection.py +561 -0
  28. package/latticeai/brain/provenance.py +401 -0
  29. package/latticeai/brain/retrieval.py +1316 -0
  30. package/latticeai/brain/schema.py +640 -0
  31. package/latticeai/brain/store.py +216 -0
  32. package/latticeai/brain/write_master.py +225 -0
  33. package/latticeai/core/invitations.py +131 -0
  34. package/latticeai/core/marketplace.py +1 -1
  35. package/latticeai/core/multi_agent.py +1 -1
  36. package/latticeai/core/policy.py +54 -0
  37. package/latticeai/core/realtime.py +65 -44
  38. package/latticeai/core/sessions.py +31 -5
  39. package/latticeai/core/users.py +147 -0
  40. package/latticeai/core/workspace_os.py +420 -20
  41. package/latticeai/services/agent_runtime.py +242 -4
  42. package/latticeai/services/run_executor.py +328 -0
  43. package/latticeai/services/workspace_service.py +27 -19
  44. package/package.json +2 -14
  45. package/scripts/lint_v3.mjs +23 -0
  46. package/static/v3/asset-manifest.json +21 -14
  47. package/static/v3/js/{app.356e6452.js → app.c5c80c46.js} +1 -1
  48. package/static/v3/js/core/{api.7a308b89.js → api.ba0fbf14.js} +58 -1
  49. package/static/v3/js/core/api.js +57 -0
  50. package/static/v3/js/core/i18n.880e1fec.js +575 -0
  51. package/static/v3/js/core/i18n.js +575 -0
  52. package/static/v3/js/core/routes.37522821.js +101 -0
  53. package/static/v3/js/core/routes.js +71 -63
  54. package/static/v3/js/core/{shell.a1657f20.js → shell.e3f6bbfa.js} +67 -38
  55. package/static/v3/js/core/shell.js +65 -36
  56. package/static/v3/js/core/{store.204a08b2.js → store.7b2aa044.js} +10 -0
  57. package/static/v3/js/core/store.js +10 -0
  58. package/static/v3/js/views/account.eff40715.js +143 -0
  59. package/static/v3/js/views/account.js +143 -0
  60. package/static/v3/js/views/activity.0d271ef9.js +67 -0
  61. package/static/v3/js/views/activity.js +67 -0
  62. package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
  63. package/static/v3/js/views/admin-users.js +4 -6
  64. package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
  65. package/static/v3/js/views/agents.js +35 -12
  66. package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
  67. package/static/v3/js/views/chat.js +23 -0
  68. package/static/v3/js/views/{knowledge-graph.5e40cbeb.js → knowledge-graph.4d09c537.js} +27 -7
  69. package/static/v3/js/views/knowledge-graph.js +27 -7
  70. package/static/v3/js/views/network.52a4f181.js +97 -0
  71. package/static/v3/js/views/network.js +97 -0
  72. package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
  73. package/static/v3/js/views/planning.js +26 -5
  74. package/static/v3/js/views/runs.b63b2afa.js +144 -0
  75. package/static/v3/js/views/runs.js +144 -0
  76. package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
  77. package/static/v3/js/views/settings.js +7 -8
  78. package/static/v3/js/views/snapshots.6f5db095.js +135 -0
  79. package/static/v3/js/views/snapshots.js +135 -0
  80. package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
  81. package/static/v3/js/views/workflows.js +87 -2
  82. package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
  83. package/static/v3/js/views/workspace-admin.js +156 -0
  84. package/static/account.html +0 -113
  85. package/static/activity.html +0 -73
  86. package/static/admin.html +0 -486
  87. package/static/agents.html +0 -139
  88. package/static/chat.html +0 -841
  89. package/static/css/reference/account.css +0 -439
  90. package/static/css/reference/admin.css +0 -610
  91. package/static/css/reference/base.css +0 -1661
  92. package/static/css/reference/chat.css +0 -4623
  93. package/static/css/reference/graph.css +0 -1016
  94. package/static/css/responsive.css +0 -861
  95. package/static/graph.html +0 -122
  96. package/static/platform.css +0 -104
  97. package/static/plugins.html +0 -136
  98. package/static/scripts/account.js +0 -238
  99. package/static/scripts/admin.js +0 -1614
  100. package/static/scripts/chat.js +0 -5081
  101. package/static/scripts/graph.js +0 -1804
  102. package/static/scripts/platform.js +0 -64
  103. package/static/scripts/ux.js +0 -167
  104. package/static/scripts/workspace.js +0 -948
  105. package/static/v3/js/core/routes.7222343d.js +0 -93
  106. package/static/workflows.html +0 -146
  107. package/static/workspace.css +0 -1121
  108. package/static/workspace.html +0 -357
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "4.0.0"
3
+ __version__ = "4.0.1"
@@ -55,6 +55,7 @@ def create_admin_router(
55
55
  invite_code: str,
56
56
  invite_gate_enabled: bool,
57
57
  default_port: int,
58
+ policy_matrix: Optional[Callable[[], List[Dict[str, object]]]] = None,
58
59
  ) -> APIRouter:
59
60
  router = APIRouter()
60
61
 
@@ -109,16 +110,6 @@ def create_admin_router(
109
110
  report["graph"] = {"error": str(e)}
110
111
  return report
111
112
 
112
- # Canonical RBAC capability map — which product areas each role can reach.
113
- # This is the real access policy the app enforces, not sample data.
114
- _ROLE_CAPS = {
115
- "owner": ["all"],
116
- "admin": ["users", "policies", "audit", "security", "chat", "search", "files", "pipeline"],
117
- "member": ["chat", "search", "files", "pipeline"],
118
- "user": ["chat", "search", "files", "pipeline"],
119
- "viewer": ["chat", "search"],
120
- }
121
-
122
113
  @router.get("/admin/roles")
123
114
  async def admin_roles(request: Request):
124
115
  _, users = require_admin(request)
@@ -126,13 +117,21 @@ def create_admin_router(
126
117
  for email, user in users.items():
127
118
  role = (get_user_role(email, users) or "user").lower()
128
119
  counts[role] += 1
129
- # Always surface the two RBAC tiers the app actually distinguishes so the
130
- # matrix is meaningful even before extra users exist.
131
- for base in ("admin", "user"):
132
- counts.setdefault(base, counts.get(base, 0))
120
+ matrix = policy_matrix() if policy_matrix else [
121
+ {"role": "admin", "caps": ["all"]},
122
+ {"role": "user", "caps": ["chat", "search"]},
123
+ ]
124
+ policy_caps = {
125
+ str(item.get("role") or "user"): list(item.get("caps") or [])
126
+ for item in matrix
127
+ if isinstance(item, dict)
128
+ }
129
+ for role in policy_caps:
130
+ counts.setdefault(role, 0)
131
+ order = {"owner": 0, "admin": 1, "member": 2, "user": 3, "viewer": 4}
133
132
  roles = [
134
- {"role": role, "members": counts.get(role, 0), "caps": _ROLE_CAPS.get(role, ["chat", "search"])}
135
- for role in sorted(counts, key=lambda r: (r != "owner", r != "admin", r))
133
+ {"role": role, "members": counts.get(role, 0), "caps": policy_caps.get(role, [])}
134
+ for role in sorted(counts, key=lambda r: (order.get(r, 99), r))
136
135
  ]
137
136
  return {"roles": roles}
138
137
 
@@ -14,6 +14,8 @@ from typing import Any, Callable, Dict, List, Optional
14
14
  from fastapi import APIRouter, HTTPException, Request
15
15
  from pydantic import BaseModel
16
16
 
17
+ from latticeai.api.ui_redirects import app_redirect
18
+
17
19
 
18
20
  class AgentRunRequest(BaseModel):
19
21
  goal: str
@@ -41,6 +43,7 @@ def create_agents_router(
41
43
  ui_file_response: Optional[Callable[[Path], Any]] = None,
42
44
  static_dir: Optional[Path] = None,
43
45
  agent_runtime: Any = None,
46
+ run_executor: Any = None,
44
47
  ) -> APIRouter:
45
48
  from latticeai.core.multi_agent import AGENT_ROLES, ROLE_AGENT_IDS
46
49
  from latticeai.services.agent_runtime import AgentRuntime
@@ -91,12 +94,7 @@ def create_agents_router(
91
94
  @router.get("/agents")
92
95
  async def agents_page(request: Request):
93
96
  require_user(request)
94
- if ui_file_response is None or static_dir is None:
95
- raise HTTPException(status_code=404, detail="Multi-Agent UI not available.")
96
- page = static_dir / "agents.html"
97
- if not page.exists():
98
- raise HTTPException(status_code=404, detail="Multi-Agent UI not found.")
99
- return ui_file_response(page)
97
+ return app_redirect("agents", request)
100
98
 
101
99
  @router.get("/agents/api/roles")
102
100
  async def agent_roles(request: Request):
@@ -163,6 +161,15 @@ def create_agents_router(
163
161
  current_user = require_user(request)
164
162
  scope = gate_write(request)
165
163
  try:
164
+ if run_executor is not None:
165
+ return await run_executor.start_agent(
166
+ req.goal,
167
+ user_email=current_user or None,
168
+ scope=scope,
169
+ roles=req.roles or None,
170
+ inputs=req.inputs,
171
+ max_retries=req.max_retries,
172
+ )
166
173
  # Worker thread: an LLM-backed run blocks on model generation and
167
174
  # must not stall the event loop (the sync model bridge also
168
175
  # requires a loop-free thread).
@@ -12,6 +12,7 @@ from fastapi import APIRouter, HTTPException, Request
12
12
  from fastapi.responses import JSONResponse, RedirectResponse
13
13
  from pydantic import BaseModel
14
14
 
15
+ from latticeai.core.users import normalize_email
15
16
  from latticeai.core.oidc import (
16
17
  OIDCValidationError,
17
18
  fetch_jwks as _default_fetch_jwks,
@@ -66,6 +67,7 @@ def create_auth_router(
66
67
  open_registration: bool,
67
68
  session_ttl: int,
68
69
  require_auth: bool = True,
70
+ ensure_identity: Optional[Callable[[str, Dict], None]] = None,
69
71
  verify_id_token: Callable[..., Dict] = _default_verify_id_token,
70
72
  fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
71
73
  ) -> APIRouter:
@@ -87,17 +89,20 @@ def create_auth_router(
87
89
  if not open_registration:
88
90
  raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
89
91
  _enforce_password_policy(req.password)
92
+ email = normalize_email(req.email)
90
93
  users = load_users()
91
- if req.email in users:
94
+ if email in users:
92
95
  raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
93
96
  role = "admin" if not users else "user"
94
- users[req.email] = {
97
+ users[email] = {
95
98
  "password": hash_password(req.password),
96
99
  "name": req.name,
97
100
  "nickname": req.nickname,
98
101
  "role": role,
99
102
  "disabled": False,
100
103
  }
104
+ if ensure_identity is not None:
105
+ ensure_identity(email, users[email])
101
106
  save_users(users)
102
107
  msg = "회원가입 성공! 첫 번째 사용자로 관리자 권한이 부여되었습니다." if role == "admin" else "회원가입 성공!"
103
108
  return {"status": "ok", "message": msg, "role": role}
@@ -105,19 +110,20 @@ def create_auth_router(
105
110
  @router.post("/login")
106
111
  async def login(req: UserLogin, request: Request):
107
112
  check_ip_rate_limit(client_ip(request), "login", max_calls=10, window_secs=300)
113
+ email = normalize_email(req.email)
108
114
  users = load_users()
109
- user = users.get(req.email)
110
- if not user or not verify_and_migrate(req.email, req.password, user.get("password", ""), users):
115
+ user = users.get(email)
116
+ if not user or not verify_and_migrate(email, req.password, user.get("password", ""), users):
111
117
  raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다.")
112
118
  if user.get("disabled"):
113
119
  raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
114
- role = get_user_role(req.email, users)
115
- token = create_session(req.email)
120
+ role = get_user_role(email, users)
121
+ token = create_session(email)
116
122
  response = JSONResponse(content={
117
123
  "status": "ok",
118
124
  "nickname": user["nickname"],
119
125
  "name": user["name"],
120
- "email": req.email,
126
+ "email": email,
121
127
  "role": role,
122
128
  "is_admin": role == "admin",
123
129
  })
@@ -202,7 +208,7 @@ def create_auth_router(
202
208
  except Exception as exc: # discovery/JWKS fetch failure → fail closed
203
209
  logging.warning("SSO token validation error: %s", exc)
204
210
  raise HTTPException(status_code=502, detail="SSO 공급자 검증에 실패했습니다.")
205
- email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
211
+ email = normalize_email(payload.get("email") or payload.get("preferred_username") or payload.get("upn") or "")
206
212
  if not email:
207
213
  raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
208
214
  users = load_users()
@@ -216,6 +222,8 @@ def create_auth_router(
216
222
  "disabled": False,
217
223
  "sso": True,
218
224
  }
225
+ if ensure_identity is not None:
226
+ ensure_identity(email, users[email])
219
227
  save_users(users)
220
228
  if users[email].get("disabled"):
221
229
  raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
@@ -235,7 +243,7 @@ def create_auth_router(
235
243
 
236
244
  @router.post("/account/change-password")
237
245
  async def change_password(req: ChangePasswordRequest, request: Request):
238
- email = require_user(request)
246
+ email = normalize_email(require_user(request))
239
247
  if not email:
240
248
  raise HTTPException(status_code=401, detail="인증이 필요합니다.")
241
249
  _enforce_password_policy(req.new_password)
@@ -251,7 +259,7 @@ def create_auth_router(
251
259
 
252
260
  @router.patch("/account/profile")
253
261
  async def update_profile(req: UpdateProfileRequest, request: Request):
254
- email = require_user(request)
262
+ email = normalize_email(require_user(request))
255
263
  if not email:
256
264
  raise HTTPException(status_code=401, detail="인증이 필요합니다.")
257
265
  if req.name is not None and not req.name.strip():
@@ -271,7 +279,7 @@ def create_auth_router(
271
279
 
272
280
  @router.get("/account/profile")
273
281
  async def get_profile(request: Request):
274
- email = require_user(request)
282
+ email = normalize_email(require_user(request))
275
283
  if not email:
276
284
  if require_auth:
277
285
  raise HTTPException(status_code=401, detail="인증이 필요합니다.")
@@ -0,0 +1,100 @@
1
+ """Invitation API: create, list, and accept workspace invitations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class InvitationCreateRequest(BaseModel):
12
+ email: Optional[str] = None
13
+ workspace_id: Optional[str] = None
14
+ role: str = "member"
15
+ expires_hours: int = 168
16
+
17
+
18
+ def create_invitations_router(
19
+ *,
20
+ invitation_store,
21
+ workspace_service,
22
+ require_admin: Callable,
23
+ require_user: Callable[[Request], str],
24
+ user_id_for_email: Callable[[Optional[str]], Optional[str]],
25
+ append_audit_event: Callable[..., None],
26
+ ) -> APIRouter:
27
+ router = APIRouter()
28
+
29
+ @router.get("/invitations")
30
+ async def list_invitations(request: Request):
31
+ require_admin(request)
32
+ return {"invitations": invitation_store.list()}
33
+
34
+ @router.post("/invitations")
35
+ async def create_invitation(req: InvitationCreateRequest, request: Request):
36
+ admin_email, _ = require_admin(request)
37
+ actor_id = user_id_for_email(admin_email)
38
+ if req.workspace_id:
39
+ try:
40
+ workspace_service.store._require_permission(
41
+ workspace_service.store._load_org(workspace_service.store.load_state(), req.workspace_id),
42
+ actor_id,
43
+ "manage_members",
44
+ )
45
+ except FileNotFoundError as exc:
46
+ raise HTTPException(status_code=404, detail=f"Workspace not found: {req.workspace_id}") from exc
47
+ except ValueError as exc:
48
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
49
+ except PermissionError as exc:
50
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
51
+ if req.role not in {"owner", "admin", "member", "viewer"}:
52
+ raise HTTPException(status_code=400, detail="unknown invitation role")
53
+ invitation = invitation_store.create(
54
+ email=req.email,
55
+ workspace_id=req.workspace_id,
56
+ role=req.role,
57
+ created_by=actor_id,
58
+ expires_hours=req.expires_hours,
59
+ )
60
+ append_audit_event(
61
+ "invitation_created",
62
+ user_email=admin_email,
63
+ invitation_id=invitation.get("id"),
64
+ workspace_id=req.workspace_id,
65
+ role=req.role,
66
+ )
67
+ return {"invitation": invitation}
68
+
69
+ @router.post("/invitations/{token}/accept")
70
+ async def accept_invitation(token: str, request: Request):
71
+ email = require_user(request)
72
+ user_id = user_id_for_email(email)
73
+ if not user_id:
74
+ raise HTTPException(status_code=401, detail="Authentication required")
75
+ try:
76
+ invitation = invitation_store.accept(token, accepted_by=user_id, email=email or None)
77
+ except FileNotFoundError as exc:
78
+ raise HTTPException(status_code=404, detail="Invitation not found") from exc
79
+ except PermissionError as exc:
80
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
81
+ workspace_id = invitation.get("workspace_id")
82
+ if workspace_id:
83
+ try:
84
+ workspace_service.add_member(
85
+ workspace_id,
86
+ user_id=user_id,
87
+ role=invitation.get("role") or "member",
88
+ actor=invitation.get("created_by"),
89
+ )
90
+ except Exception as exc:
91
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
92
+ append_audit_event(
93
+ "invitation_accepted",
94
+ user_email=email,
95
+ invitation_id=invitation.get("id"),
96
+ workspace_id=workspace_id,
97
+ )
98
+ return {"invitation": invitation}
99
+
100
+ return router
@@ -10,9 +10,10 @@ from pathlib import Path
10
10
  from typing import Any, Callable, Dict, Optional
11
11
 
12
12
  from fastapi import APIRouter, HTTPException, Request
13
- from fastapi.responses import FileResponse
14
13
  from pydantic import BaseModel
15
14
 
15
+ from latticeai.api.ui_redirects import app_redirect
16
+
16
17
 
17
18
  class KnowledgeGraphIngestRequest(BaseModel):
18
19
  type: str
@@ -44,22 +45,14 @@ def create_knowledge_graph_router(
44
45
  """Serve the interactive knowledge graph canvas UI."""
45
46
  graph()
46
47
  require_user(request)
47
- response = FileResponse(static_dir / "graph.html")
48
- response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
49
- response.headers["Pragma"] = "no-cache"
50
- response.headers["Expires"] = "0"
51
- return response
48
+ return app_redirect("knowledge-graph", request)
52
49
 
53
50
  @router.get("/knowledge-graph")
54
51
  async def knowledge_graph_legacy_page(request: Request):
55
52
  """Backward-compatible route for the graph page."""
56
53
  graph()
57
54
  require_user(request)
58
- response = FileResponse(static_dir / "graph.html")
59
- response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
60
- response.headers["Pragma"] = "no-cache"
61
- response.headers["Expires"] = "0"
62
- return response
55
+ return app_redirect("knowledge-graph", request)
63
56
 
64
57
  @router.post("/knowledge-graph/curate")
65
58
  async def knowledge_graph_curate(request: Request):
@@ -15,6 +15,8 @@ from typing import Any, Callable, Dict, Optional
15
15
  from fastapi import APIRouter, HTTPException, Request
16
16
  from pydantic import BaseModel
17
17
 
18
+ from latticeai.api.ui_redirects import app_redirect
19
+
18
20
 
19
21
  class PluginActionRequest(BaseModel):
20
22
  plugin_id: str
@@ -50,12 +52,7 @@ def create_plugins_router(
50
52
  @router.get("/plugins/sdk")
51
53
  async def plugins_sdk_page(request: Request):
52
54
  require_user(request)
53
- if ui_file_response is None or static_dir is None:
54
- raise HTTPException(status_code=404, detail="Plugin SDK UI not available.")
55
- page = static_dir / "plugins.html"
56
- if not page.exists():
57
- raise HTTPException(status_code=404, detail="Plugin SDK UI not found.")
58
- return ui_file_response(page)
55
+ return app_redirect("marketplace", request)
59
56
 
60
57
  @router.get("/plugins/registry")
61
58
  async def plugins_registry(request: Request):
@@ -12,10 +12,12 @@ import secrets
12
12
  from pathlib import Path
13
13
  from typing import Any, Callable, Optional, Set
14
14
 
15
- from fastapi import APIRouter, HTTPException, Request
15
+ from fastapi import APIRouter, Request
16
16
  from fastapi.responses import StreamingResponse
17
17
  from pydantic import BaseModel
18
18
 
19
+ from latticeai.api.ui_redirects import app_redirect
20
+
19
21
 
20
22
  class PresenceRequest(BaseModel):
21
23
  client_id: Optional[str] = None
@@ -36,12 +38,7 @@ def create_realtime_router(
36
38
  @router.get("/activity")
37
39
  async def activity_page(request: Request):
38
40
  require_user(request)
39
- if ui_file_response is None or static_dir is None:
40
- raise HTTPException(status_code=404, detail="Activity UI not available.")
41
- page = static_dir / "activity.html"
42
- if not page.exists():
43
- raise HTTPException(status_code=404, detail="Activity UI not found.")
44
- return ui_file_response(page)
41
+ return app_redirect("activity", request)
45
42
 
46
43
  @router.get("/realtime/stream")
47
44
  async def realtime_stream(request: Request):
@@ -10,6 +10,8 @@ from typing import Callable, Optional
10
10
  from fastapi import APIRouter, Cookie, HTTPException, Request
11
11
  from fastapi.responses import FileResponse, HTMLResponse
12
12
 
13
+ from latticeai.api.ui_redirects import app_redirect
14
+
13
15
  def ui_file_response(path: Path) -> FileResponse:
14
16
  response = FileResponse(path)
15
17
  response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
@@ -44,15 +46,15 @@ def create_static_routes_router(
44
46
  async def root(request: Request, code: Optional[str] = None, authorized: Optional[str] = Cookie(None)):
45
47
  """로그인/회원가입 페이지. 초대 게이트 활성화 시 코드 검증 후 진입."""
46
48
  if not INVITE_GATE_ENABLED:
47
- return ui_file_response(STATIC_DIR / "account.html")
49
+ return app_redirect("account", request)
48
50
 
49
51
  # 1. 이미 쿠키로 인증된 경우
50
52
  if authorized == "true":
51
- return ui_file_response(STATIC_DIR / "account.html")
53
+ return app_redirect("account", request)
52
54
 
53
55
  # 2. 초대 코드가 일치하는 경우 (최초 진입)
54
56
  if code == INVITE_CODE:
55
- response = ui_file_response(STATIC_DIR / "account.html")
57
+ response = app_redirect("account", request)
56
58
  response.set_cookie(key="authorized", value="true", httponly=True, samesite="lax", max_age=60*60*24*7)
57
59
  return response
58
60
 
@@ -72,7 +74,7 @@ def create_static_routes_router(
72
74
  @api_router.get("/account")
73
75
  async def account_page():
74
76
  """Direct login/register page route used by logout and manual navigation."""
75
- return ui_file_response(STATIC_DIR / "account.html")
77
+ return app_redirect("account")
76
78
 
77
79
 
78
80
  @api_router.get("/manifest.json")
@@ -105,7 +107,7 @@ def create_static_routes_router(
105
107
 
106
108
  @api_router.get("/chat")
107
109
  async def chat_page(request: Request):
108
- return ui_file_response(STATIC_DIR / "chat.html")
110
+ return app_redirect("chat", request)
109
111
 
110
112
 
111
113
  @api_router.get("/app")
@@ -118,13 +120,8 @@ def create_static_routes_router(
118
120
 
119
121
 
120
122
  @api_router.get("/admin")
121
- async def admin_page():
122
- admin_path = STATIC_DIR / "admin.html"
123
- if not admin_path.exists():
124
- raise HTTPException(status_code=404, detail="Admin UI not found.")
125
- response = FileResponse(admin_path)
126
- response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
127
- return response
123
+ async def admin_page(request: Request):
124
+ return app_redirect("admin/users", request)
128
125
 
129
126
  # /workspace and /onboarding UI pages are served by the workspace router
130
127
  # (latticeai.api.workspace), included below after its dependencies are defined.
@@ -0,0 +1,26 @@
1
+ """Compatibility redirects from retired legacy pages into the v4 SPA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from fastapi import Request
8
+ from fastapi.responses import RedirectResponse
9
+
10
+
11
+ def app_redirect(fragment: str, request: Optional[Request] = None) -> RedirectResponse:
12
+ """Redirect a legacy GET route to the equivalent /app hash route.
13
+
14
+ Existing browser bookmarks keep working while the legacy HTML/JS/CSS pages
15
+ are removed from the shipped artifact. Query strings are preserved after the
16
+ hash so SPA route params remain addressable.
17
+ """
18
+
19
+ frag = fragment.strip("/")
20
+ query = ""
21
+ if request is not None and request.url.query:
22
+ query = f"?{request.url.query}"
23
+ return RedirectResponse(url=f"/app#/{frag}{query}", status_code=308)
24
+
25
+
26
+ __all__ = ["app_redirect"]
@@ -19,6 +19,8 @@ from typing import Any, Callable, Dict, List, Optional
19
19
  from fastapi import APIRouter, HTTPException, Request
20
20
  from pydantic import BaseModel
21
21
 
22
+ from latticeai.api.ui_redirects import app_redirect
23
+
22
24
 
23
25
  class WorkflowDefinitionRequest(BaseModel):
24
26
  name: str
@@ -62,6 +64,8 @@ def create_workflow_designer_router(
62
64
  ui_file_response: Optional[Callable[[Path], Any]] = None,
63
65
  static_dir: Optional[Path] = None,
64
66
  hooks: Any = None,
67
+ run_executor: Any = None,
68
+ trigger_service: Any = None,
65
69
  ) -> APIRouter:
66
70
  from latticeai.core.workflow_engine import (
67
71
  WorkflowEngine,
@@ -76,12 +80,7 @@ def create_workflow_designer_router(
76
80
  @router.get("/workflows")
77
81
  async def workflows_page(request: Request):
78
82
  require_user(request)
79
- if ui_file_response is None or static_dir is None:
80
- raise HTTPException(status_code=404, detail="Workflow Designer UI not available.")
81
- page = static_dir / "workflows.html"
82
- if not page.exists():
83
- raise HTTPException(status_code=404, detail="Workflow Designer UI not found.")
84
- return ui_file_response(page)
83
+ return app_redirect("workflows", request)
85
84
 
86
85
  @router.get("/workflows/api/definitions")
87
86
  async def list_definitions(request: Request, q: str = ""):
@@ -151,6 +150,16 @@ def create_workflow_designer_router(
151
150
  workflow = store.get_workflow(workflow_id, workspace_id=scope)
152
151
  except FileNotFoundError as exc:
153
152
  raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
153
+ if run_executor is not None:
154
+ result = await run_executor.start_workflow(
155
+ workflow,
156
+ workflow_id=workflow_id,
157
+ user_email=current_user or None,
158
+ scope=scope,
159
+ inputs=req.inputs,
160
+ )
161
+ append_audit_event("workflow_run_queued", user_email=current_user, workflow_id=workflow_id, status="queued")
162
+ return result
154
163
  runners = build_runners(current_user or None, scope)
155
164
  engine = WorkflowEngine(runners, hooks=hooks)
156
165
  result = engine.run(workflow, inputs=req.inputs)
@@ -170,6 +179,23 @@ def create_workflow_designer_router(
170
179
  append_audit_event("workflow_run", user_email=current_user, workflow_id=workflow_id, status=result.status)
171
180
  return {"run": run, "result": result.as_dict()}
172
181
 
182
+ @router.post("/workflows/api/runs/{run_id}/stop")
183
+ async def stop_run(run_id: str, request: Request):
184
+ require_user(request)
185
+ scope = gate_write(request)
186
+ if run_executor is None:
187
+ try:
188
+ run = store.get_workflow_run(run_id, workspace_id=scope)
189
+ except FileNotFoundError as exc:
190
+ raise HTTPException(status_code=404, detail=f"Workflow run not found: {run_id}") from exc
191
+ return {
192
+ "stopped": False,
193
+ "reason": "asynchronous cancellation is not supported by the synchronous runtime",
194
+ "run_id": run_id,
195
+ "status": run.get("status"),
196
+ }
197
+ return run_executor.cancel(run_id, kind="workflow", scope=scope)
198
+
173
199
  @router.post("/workflows/api/runs/{run_id}/resume")
174
200
  async def resume_run(run_id: str, req: WorkflowResumeRequest, request: Request):
175
201
  """Decide a paused (awaiting_approval) run: approve → the paused node
@@ -221,6 +247,13 @@ def create_workflow_designer_router(
221
247
  scope = gate_read(request)
222
248
  return store.list_workflow_runs(limit=limit, workspace_id=scope)
223
249
 
250
+ @router.get("/workflows/api/triggers")
251
+ async def trigger_status(request: Request):
252
+ require_user(request)
253
+ if trigger_service is None:
254
+ return {"running": False, "tick_seconds": None, "armed": []}
255
+ return trigger_service.describe()
256
+
224
257
  @router.get("/workflows/api/runs/{run_id}/replay")
225
258
  async def workflow_run_replay(run_id: str, request: Request):
226
259
  require_user(request)
@@ -20,6 +20,7 @@ from typing import Dict, List, Optional
20
20
  from fastapi import APIRouter, HTTPException, Request
21
21
  from pydantic import BaseModel
22
22
 
23
+ from latticeai.api.ui_redirects import app_redirect
23
24
  from latticeai.services.app_context import AppContext
24
25
 
25
26
 
@@ -161,7 +162,6 @@ def create_workspace_router(context: AppContext) -> APIRouter:
161
162
  remove_skill_directory = context.remove_skill_directory
162
163
  redact_secret_text = context.redact_secret_text
163
164
  capability_registry = context.capability_registry
164
- ui_file_response = context.ui_file_response
165
165
 
166
166
  svc = service
167
167
  WORKSPACE_OS = service.store
@@ -173,7 +173,6 @@ def create_workspace_router(context: AppContext) -> APIRouter:
173
173
  KNOWLEDGE_GRAPH = context.knowledge_graph
174
174
  LOCAL_KG_WATCHER = context.local_kg_watcher
175
175
  SKILLS_DIR = context.skills_dir
176
- STATIC_DIR = context.static_dir
177
176
  LOCAL_MODEL = context.local_model
178
177
  PUBLIC_MODEL = context.public_model
179
178
  _fetch_skills_marketplace = context.fetch_skills_marketplace
@@ -214,18 +213,12 @@ def create_workspace_router(context: AppContext) -> APIRouter:
214
213
  @router.get("/workspace")
215
214
  async def workspace_page(request: Request):
216
215
  require_user(request)
217
- workspace_path = STATIC_DIR / "workspace.html"
218
- if not workspace_path.exists():
219
- raise HTTPException(status_code=404, detail="Workspace OS UI not found.")
220
- return ui_file_response(workspace_path)
216
+ return app_redirect("workspace-admin", request)
221
217
 
222
218
  @router.get("/onboarding")
223
219
  async def onboarding_page(request: Request):
224
220
  require_user(request)
225
- workspace_path = STATIC_DIR / "workspace.html"
226
- if not workspace_path.exists():
227
- raise HTTPException(status_code=404, detail="Workspace OS UI not found.")
228
- return ui_file_response(workspace_path)
221
+ return app_redirect("workspace-admin", request)
229
222
 
230
223
  # ── Workspace OS summary / onboarding ─────────────────────────────────
231
224
 
@@ -387,6 +380,27 @@ def create_workspace_router(context: AppContext) -> APIRouter:
387
380
  append_audit_event("workspace_snapshot_export", user_email=current_user, snapshot_id=snapshot_id, path=result.get("export_path"))
388
381
  return result
389
382
 
383
+ @router.post("/workspace/snapshots/{snapshot_id}/restore")
384
+ async def workspace_snapshot_restore(snapshot_id: str, request: Request):
385
+ current_user = require_user(request)
386
+ snapshot = _load_snapshot_authorized(request, snapshot_id)
387
+ scope = _gate_write(request)
388
+ if snapshot.get("workspace_id") and snapshot.get("workspace_id") != scope:
389
+ raise HTTPException(status_code=403, detail="snapshot belongs to a different workspace")
390
+ try:
391
+ result = WORKSPACE_OS.restore_snapshot(
392
+ snapshot_id,
393
+ graph=_workspace_graph(),
394
+ workspace_id=scope,
395
+ user_email=current_user or None,
396
+ )
397
+ except FileNotFoundError as exc:
398
+ raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
399
+ except ValueError as exc:
400
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
401
+ append_audit_event("workspace_snapshot_restore", user_email=current_user, snapshot_id=snapshot_id, restore_id=result.get("restore", {}).get("id"))
402
+ return result
403
+
390
404
  @router.get("/workspace/time-machine")
391
405
  async def workspace_time_machine(request: Request, limit: int = 100):
392
406
  require_user(request)