ltcai 10.7.0 → 10.8.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 (77) hide show
  1. package/README.md +52 -39
  2. package/docs/CHANGELOG.md +31 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/retrieval_vector.py +69 -33
  12. package/lattice_brain/runtime/multi_agent.py +96 -15
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +9 -6
  15. package/latticeai/api/auth.py +37 -38
  16. package/latticeai/api/browser.py +7 -6
  17. package/latticeai/core/file_generation.py +141 -8
  18. package/latticeai/core/legacy_compatibility.py +1 -1
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/messages.py +230 -0
  21. package/latticeai/core/workspace_computer_memory.py +84 -0
  22. package/latticeai/core/workspace_indexing.py +102 -0
  23. package/latticeai/core/workspace_onboarding.py +104 -0
  24. package/latticeai/core/workspace_os.py +24 -207
  25. package/latticeai/core/workspace_os_constants.py +1 -1
  26. package/latticeai/core/workspace_relationships.py +99 -0
  27. package/latticeai/services/architecture_readiness.py +1 -1
  28. package/latticeai/services/product_readiness.py +1 -1
  29. package/package.json +1 -1
  30. package/scripts/check_current_release_docs.mjs +1 -1
  31. package/scripts/check_screenshot_pixel_delta.py +92 -10
  32. package/scripts/release_screen_claims.json +37 -0
  33. package/src-tauri/Cargo.lock +1 -1
  34. package/src-tauri/Cargo.toml +1 -1
  35. package/src-tauri/tauri.conf.json +1 -1
  36. package/static/app/asset-manifest.json +37 -37
  37. package/static/app/assets/{Act-DIbkoJqs.js → Act-CbQNsFgc.js} +1 -1
  38. package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-Bs8SR8fE.js} +1 -1
  39. package/static/app/assets/{Brain-BPfuXZ71.js → Brain-BZIPsZlH.js} +1 -1
  40. package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-BaH5MDce.js} +1 -1
  41. package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-aDt-nSsW.js} +1 -1
  42. package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-DDv940Bv.js} +1 -1
  43. package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-Dq_MmPBl.js} +1 -1
  44. package/static/app/assets/{Library-BRlJJmwZ.js → Library-BkShbqmN.js} +1 -1
  45. package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-CXb8094H.js} +1 -1
  46. package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
  47. package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-Be7E7X18.js} +1 -1
  48. package/static/app/assets/{System-Ci0HEtTJ.js → System-DCstbTxC.js} +1 -1
  49. package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
  50. package/static/app/assets/{bot-BaPt4PPn.js → bot-BR0eoeXY.js} +1 -1
  51. package/static/app/assets/{brain-BSkWaj3i.js → brain-BmWa1wea.js} +1 -1
  52. package/static/app/assets/{button-CKwtO1O0.js → button-U1nU7xX4.js} +1 -1
  53. package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-DG5KHjDL.js} +1 -1
  54. package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Chhcuq90.js} +1 -1
  55. package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-pkOSlT9h.js} +1 -1
  56. package/static/app/assets/{download-C32oGU-C.js → download-CSRwz923.js} +1 -1
  57. package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-DiMJ7W_7.js} +1 -1
  58. package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-iyLPAPdp.js} +1 -1
  59. package/static/app/assets/index-BZRvGOM2.css +2 -0
  60. package/static/app/assets/{index-J6h01X78.js → index-_ldE9TAX.js} +3 -3
  61. package/static/app/assets/{input-y6ABdKWY.js → input-e_r2hFAY.js} +1 -1
  62. package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-9uOHktz3.js} +1 -1
  63. package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CnEAOmg2.js} +1 -1
  64. package/static/app/assets/search-veep4n6f.js +1 -0
  65. package/static/app/assets/{share-2-BgGq2A63.js → share-2-DLDJJKmI.js} +1 -1
  66. package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BTtE-2EZ.js} +1 -1
  67. package/static/app/assets/{textarea-BAcim3fs.js → textarea-Bbzkcgqe.js} +1 -1
  68. package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-DJzQd-Za.js} +1 -1
  69. package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-6R_tqHRe.js} +1 -1
  70. package/static/app/assets/{utils-CogdofAA.js → utils-XDsjcrhS.js} +1 -1
  71. package/static/app/assets/{workspace-BMvfx9_R.js → workspace-CwrG8LHq.js} +1 -1
  72. package/static/app/index.html +4 -4
  73. package/static/sw.js +1 -1
  74. package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
  75. package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
  76. package/static/app/assets/index-BV7Yf6Eq.css +0 -2
  77. package/static/app/assets/search-CaIZKyo_.js +0 -1
@@ -0,0 +1,230 @@
1
+ """Server-side message catalog.
2
+
3
+ Every string the API hands a person — an ``HTTPException`` detail, a status
4
+ line, a refusal — used to be a literal at the raise site, and the literals were
5
+ written in whichever language the author happened to be thinking in. The result
6
+ was one API that answered ``"사용자를 찾을 수 없습니다."`` and, two files over,
7
+ ``"Knowledge Graph ingestion is disabled."``, so *every* user read half the
8
+ product in a language they had not chosen.
9
+
10
+ The rules here are the same three the frontend catalog follows:
11
+
12
+ 1. **One id per message.** The id is what code refers to; the wording is data.
13
+ 2. **Both languages or neither.** ``test_messages.py`` fails on a key present
14
+ in one language and missing from the other, so a new message cannot ship
15
+ half-translated.
16
+ 3. **The caller never picks the language.** It comes from the request, via
17
+ :func:`resolve_language`, so a message cannot be localized to whoever wrote
18
+ the endpoint.
19
+
20
+ Usage::
21
+
22
+ from latticeai.core.messages import http_error, resolve_language
23
+
24
+ lang = resolve_language(request)
25
+ raise http_error(404, "auth.user_not_found", lang)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any, Dict, Mapping, Optional
31
+
32
+ from fastapi import HTTPException
33
+
34
+ __all__ = [
35
+ "DEFAULT_LANGUAGE",
36
+ "SUPPORTED_LANGUAGES",
37
+ "LANGUAGE_HEADER",
38
+ "MESSAGES",
39
+ "http_error",
40
+ "resolve_language",
41
+ "translate",
42
+ ]
43
+
44
+ DEFAULT_LANGUAGE = "ko"
45
+ SUPPORTED_LANGUAGES = ("ko", "en")
46
+
47
+ #: Explicit override the frontend sends. Preferred over ``Accept-Language``
48
+ #: because the person picked this language *in the product*, whereas
49
+ #: ``Accept-Language`` is whatever their browser was installed with.
50
+ LANGUAGE_HEADER = "x-lattice-language"
51
+
52
+
53
+ MESSAGES: Dict[str, Dict[str, str]] = {
54
+ # ── auth ────────────────────────────────────────────────────────────
55
+ "auth.password_too_weak": {
56
+ "ko": "비밀번호는 8자 이상이며 영문자와 숫자를 모두 포함해야 합니다.",
57
+ "en": "Your password needs at least 8 characters, including letters and numbers.",
58
+ },
59
+ "auth.invitation_required": {
60
+ "ko": "유효한 서명 초대 권한이 필요합니다.",
61
+ "en": "A valid signed invitation is required.",
62
+ },
63
+ "auth.registration_disabled": {
64
+ "ko": "회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.",
65
+ "en": "Sign-up is turned off. Ask an administrator to enable it.",
66
+ },
67
+ "auth.email_taken": {
68
+ "ko": "이미 존재하는 이메일입니다.",
69
+ "en": "That email address is already registered.",
70
+ },
71
+ "auth.bad_credentials": {
72
+ "ko": "이메일 또는 비밀번호가 틀렸습니다.",
73
+ "en": "That email address or password is not correct.",
74
+ },
75
+ "auth.account_disabled": {
76
+ "ko": "비활성화된 계정입니다.",
77
+ "en": "This account has been disabled.",
78
+ },
79
+ "auth.login_required": {
80
+ "ko": "인증이 필요합니다.",
81
+ "en": "You need to sign in first.",
82
+ },
83
+ "auth.user_not_found": {
84
+ "ko": "사용자를 찾을 수 없습니다.",
85
+ "en": "No such user.",
86
+ },
87
+ "auth.current_password_wrong": {
88
+ "ko": "현재 비밀번호가 틀렸습니다.",
89
+ "en": "Your current password is not correct.",
90
+ },
91
+ "auth.name_required": {
92
+ "ko": "이름을 입력해주세요.",
93
+ "en": "Please enter a name.",
94
+ },
95
+ "auth.nickname_required": {
96
+ "ko": "닉네임을 입력해주세요.",
97
+ "en": "Please enter a nickname.",
98
+ },
99
+ # ── SSO ─────────────────────────────────────────────────────────────
100
+ "sso.not_configured": {
101
+ "ko": "SSO가 설정되지 않았습니다.",
102
+ "en": "Single sign-on is not set up.",
103
+ },
104
+ "sso.invalid_state": {
105
+ "ko": "유효하지 않은 SSO 상태입니다.",
106
+ "en": "That sign-on request is no longer valid. Please start again.",
107
+ },
108
+ "sso.config_error": {
109
+ "ko": "SSO 설정 오류입니다.",
110
+ "en": "Single sign-on is misconfigured.",
111
+ },
112
+ "sso.no_id_token": {
113
+ "ko": "ID 토큰을 받지 못했습니다.",
114
+ "en": "The identity provider did not return an ID token.",
115
+ },
116
+ "sso.token_verification_failed": {
117
+ "ko": "SSO 토큰 검증에 실패했습니다.",
118
+ "en": "The sign-on token could not be verified.",
119
+ },
120
+ "sso.provider_verification_failed": {
121
+ "ko": "SSO 공급자 검증에 실패했습니다.",
122
+ "en": "The sign-on provider could not be verified.",
123
+ },
124
+ "sso.email_unavailable": {
125
+ "ko": "이메일을 확인할 수 없습니다.",
126
+ "en": "The identity provider did not share an email address.",
127
+ },
128
+ "sso.invitation_required": {
129
+ "ko": "신규 SSO 계정에는 유효한 서명 초대 권한이 필요합니다.",
130
+ "en": "New single sign-on accounts need a valid signed invitation.",
131
+ },
132
+ # ── admin ───────────────────────────────────────────────────────────
133
+ "admin.invalid_role": {
134
+ "ko": "role은 admin 또는 user만 가능합니다.",
135
+ "en": "Role must be either 'admin' or 'user'.",
136
+ },
137
+ "admin.cannot_disable_self": {
138
+ "ko": "자기 자신은 비활성화할 수 없습니다.",
139
+ "en": "You cannot disable your own account.",
140
+ },
141
+ "admin.cannot_delete_self": {
142
+ "ko": "자기 자신은 삭제할 수 없습니다.",
143
+ "en": "You cannot delete your own account.",
144
+ },
145
+ # ── capture / ingestion ─────────────────────────────────────────────
146
+ "capture.ingestion_disabled": {
147
+ "ko": "지식 그래프 수집이 꺼져 있습니다.",
148
+ "en": "Knowledge Graph ingestion is turned off.",
149
+ },
150
+ "capture.payload_too_large": {
151
+ "ko": "보낸 내용이 너무 큽니다.",
152
+ "en": "That capture is too large to accept.",
153
+ },
154
+ "capture.nothing_to_capture": {
155
+ "ko": "저장할 내용이 없습니다. 텍스트나 페이지 내용을 함께 보내주세요.",
156
+ "en": "Nothing to capture — send text, html, or a selection.",
157
+ },
158
+ }
159
+
160
+
161
+ def resolve_language(request: Any, default: str = DEFAULT_LANGUAGE) -> str:
162
+ """Language for this request: the product's choice, then the browser's.
163
+
164
+ Never raises and never returns an unsupported language — an endpoint that
165
+ cannot resolve a language must still be able to answer.
166
+ """
167
+ headers: Optional[Mapping[str, str]] = getattr(request, "headers", None)
168
+ if headers is None:
169
+ return default
170
+
171
+ explicit = _normalize(headers.get(LANGUAGE_HEADER))
172
+ if explicit:
173
+ return explicit
174
+
175
+ # `Accept-Language: en-GB,en;q=0.9,ko;q=0.8` — take the first tag we
176
+ # support, honouring the order the browser sent (which is the priority
177
+ # order) rather than re-sorting by q-value, since the two agree in every
178
+ # real header and disagreeing ones are malformed anyway.
179
+ for part in (headers.get("accept-language") or "").split(","):
180
+ candidate = _normalize(part.split(";", 1)[0])
181
+ if candidate:
182
+ return candidate
183
+ return default
184
+
185
+
186
+ def _normalize(value: Optional[str]) -> Optional[str]:
187
+ """``"en-GB"`` → ``"en"`` when supported, else None."""
188
+ if not value:
189
+ return None
190
+ tag = value.strip().lower().replace("_", "-")
191
+ if not tag:
192
+ return None
193
+ base = tag.split("-", 1)[0]
194
+ return base if base in SUPPORTED_LANGUAGES else None
195
+
196
+
197
+ def translate(key: str, language: str = DEFAULT_LANGUAGE, **params: Any) -> str:
198
+ """Localized text for ``key``.
199
+
200
+ An unknown key returns the key itself rather than raising: a missing
201
+ message must not turn a 404 into a 500. The namespaced key is also
202
+ recognisable in a bug report, which a generic "an error occurred" is not.
203
+ """
204
+ entry = MESSAGES.get(key)
205
+ if entry is None:
206
+ return key
207
+ text = entry.get(language) or entry.get(DEFAULT_LANGUAGE) or key
208
+ for name, value in params.items():
209
+ text = text.replace("{" + name + "}", str(value))
210
+ return text
211
+
212
+
213
+ def http_error(
214
+ status_code: int,
215
+ key: str,
216
+ language: str = DEFAULT_LANGUAGE,
217
+ *,
218
+ headers: Optional[Dict[str, str]] = None,
219
+ **params: Any,
220
+ ) -> HTTPException:
221
+ """An ``HTTPException`` whose detail is localized for this request.
222
+
223
+ Returned, not raised, so call sites keep reading ``raise http_error(...)``
224
+ and the traceback still points at the endpoint.
225
+ """
226
+ return HTTPException(
227
+ status_code=status_code,
228
+ detail=translate(key, language, **params),
229
+ headers=headers,
230
+ )
@@ -0,0 +1,84 @@
1
+ """Local Computer Memory: the opt-in that lets the Brain watch this machine.
2
+
3
+ Extracted from ``WorkspaceOSStore``. Off by default and impossible to enable
4
+ without a recorded consent — the check lives here so there is one place to read
5
+ when asking "what did this machine agree to observe".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from .timeutil import now_iso as _now
13
+ from .workspace_os_utils import _json_hash
14
+
15
+ __all__ = ["WorkspaceComputerMemory", "DEFAULT_COMPUTER_MEMORY_SCOPES"]
16
+
17
+ #: Folders the feature offers to watch when the person names none. Chosen to be
18
+ #: recognisable rather than exhaustive: the point of the list is that a person
19
+ #: reading the consent dialog knows what it means.
20
+ DEFAULT_COMPUTER_MEMORY_SCOPES = ("Downloads", "Documents", "Repositories")
21
+
22
+
23
+ class WorkspaceComputerMemory:
24
+ """Consent state and activity log for watching the local computer."""
25
+
26
+ def __init__(self, store: Any) -> None:
27
+ self.store = store
28
+
29
+ def configure(
30
+ self,
31
+ *,
32
+ enabled: bool,
33
+ approved_by: Optional[str],
34
+ consent: Optional[Dict[str, Any]] = None,
35
+ scopes: Optional[List[str]] = None,
36
+ ) -> Dict[str, Any]:
37
+ consent = consent or {}
38
+ if enabled and not consent.get("approved"):
39
+ raise PermissionError("Local Computer Memory requires explicit approval.")
40
+ state = self.store.load_state()
41
+ config = state.setdefault("computer_memory", {})
42
+ config.update({
43
+ "enabled": bool(enabled),
44
+ "approved": bool(enabled),
45
+ "approved_at": _now() if enabled else config.get("approved_at"),
46
+ "approved_by": approved_by if enabled else config.get("approved_by"),
47
+ "scopes": scopes or config.get("scopes") or list(DEFAULT_COMPUTER_MEMORY_SCOPES),
48
+ "consent": consent,
49
+ })
50
+ state.setdefault("feature_flags", {})["local_computer_memory"] = bool(enabled)
51
+ self.store.save_state(state)
52
+ self.store.record_timeline_event(
53
+ "memory",
54
+ "computer_memory_configured",
55
+ {"enabled": bool(enabled), "approved_by": approved_by},
56
+ )
57
+ return config
58
+
59
+ def record_activity(self, activity: Dict[str, Any], graph: Any = None) -> Dict[str, Any]:
60
+ state = self.store.load_state()
61
+ config = state.setdefault("computer_memory", {})
62
+ if not config.get("enabled"):
63
+ return {"status": "ignored", "reason": "local computer memory is disabled"}
64
+ record = {
65
+ "id": f"activity-{_json_hash([activity, _now()])[:16]}",
66
+ "timestamp": _now(),
67
+ **activity,
68
+ }
69
+ config.setdefault("activities", []).append(record)
70
+ if graph is not None:
71
+ # A graph that refuses the event must not lose the activity: the
72
+ # record is kept either way and carries the reason it did not land.
73
+ try:
74
+ graph.ingest_event(
75
+ "ComputerActivity",
76
+ str(activity.get("summary") or activity.get("path") or "Computer activity")[:120],
77
+ source="workspace_os",
78
+ metadata=record,
79
+ )
80
+ except Exception as exc:
81
+ record["graph_error"] = str(exc)
82
+ self.store.save_state(state)
83
+ self.store.record_timeline_event("memory", "computer_activity", {"activity_id": record["id"]})
84
+ return {"status": "ok", "activity": record}
@@ -0,0 +1,102 @@
1
+ """Indexing dashboard and per-source watch control.
2
+
3
+ Extracted from ``WorkspaceOSStore`` so the store stays a façade over composed
4
+ managers rather than a class that also knows how a file watcher reports itself.
5
+ Everything here reads the graph and the watcher and writes only timeline
6
+ events, so it needs the store for ``record_timeline_event`` and nothing else.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, Optional
12
+
13
+ __all__ = ["WorkspaceIndexing"]
14
+
15
+
16
+ class WorkspaceIndexing:
17
+ """Reads index state out of the graph; pauses, resumes, and removes sources."""
18
+
19
+ def __init__(self, store: Any) -> None:
20
+ self.store = store
21
+
22
+ def build_dashboard(
23
+ self,
24
+ graph: Any,
25
+ watcher_status: Optional[Dict[str, Any]] = None,
26
+ ) -> Dict[str, Any]:
27
+ if graph is None:
28
+ return {
29
+ "sources": [],
30
+ "watcher": watcher_status or {"available": False, "active": {}},
31
+ "totals": {"success": 0, "failed": 0, "nodes": 0, "edges": 0},
32
+ }
33
+ stats = graph.stats()
34
+ sources = graph.local_sources().get("sources", [])
35
+ watcher_status = watcher_status or {"available": False, "active": {}}
36
+ active = watcher_status.get("active", {})
37
+ dashboard_sources = []
38
+ total_success = 0
39
+ total_failed = 0
40
+ for source in sources:
41
+ file_status = source.get("file_status") or {}
42
+ success = int(file_status.get("indexed") or 0)
43
+ failed = sum(
44
+ int(file_status.get(key) or 0)
45
+ for key in ("failed", "inaccessible", "skipped_empty_text")
46
+ )
47
+ total_success += success
48
+ total_failed += failed
49
+ watch = active.get(source.get("id")) or {}
50
+ dashboard_sources.append({
51
+ "id": source.get("id"),
52
+ "label": source.get("label"),
53
+ "root_path": source.get("root_path"),
54
+ "status": source.get("status"),
55
+ "watch_enabled": bool(source.get("watch_enabled")),
56
+ "watch_active": source.get("id") in active,
57
+ "watch_status": watch,
58
+ "success_count": success,
59
+ "failure_count": failed,
60
+ "last_run_at": source.get("last_scanned_at") or source.get("updated_at"),
61
+ "file_status": file_status,
62
+ "include_ocr": bool(source.get("include_ocr")),
63
+ })
64
+ return {
65
+ "sources": dashboard_sources,
66
+ "watcher": watcher_status,
67
+ "totals": {
68
+ "success": total_success,
69
+ "failed": total_failed,
70
+ "nodes": sum(int(v or 0) for v in (stats.get("nodes") or {}).values()),
71
+ "edges": sum(int(v or 0) for v in (stats.get("edges") or {}).values()),
72
+ "local_sources": stats.get("local_sources", len(sources)),
73
+ },
74
+ "graph_stats": stats,
75
+ }
76
+
77
+ def pause(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
78
+ result = graph.set_local_source_watch(source_id, False)
79
+ watch = watcher.stop_source(source_id) if watcher else {"stopped": False, "source_id": source_id}
80
+ self.store.record_timeline_event("graph", "indexing_paused", {"source_id": source_id})
81
+ return {"status": "ok", "source": result, "watch": watch}
82
+
83
+ def resume(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
84
+ result = graph.set_local_source_watch(source_id, True)
85
+ watch = {"watching": False, "source_id": source_id}
86
+ source = next(
87
+ (item for item in graph.local_sources().get("sources", []) if item.get("id") == source_id),
88
+ None,
89
+ )
90
+ if watcher and source:
91
+ watch = watcher.start_source(source)
92
+ self.store.record_timeline_event("graph", "indexing_resumed", {"source_id": source_id})
93
+ return {"status": "ok", "source": result, "watch": watch}
94
+
95
+ def remove_source(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
96
+ if watcher:
97
+ watcher.stop_source(source_id)
98
+ if not hasattr(graph, "remove_local_source"):
99
+ raise ValueError("graph store does not support removing local sources")
100
+ result = graph.remove_local_source(source_id)
101
+ self.store.record_timeline_event("graph", "indexing_removed", {"source_id": source_id})
102
+ return {"status": "ok", **result}
@@ -0,0 +1,104 @@
1
+ """First-run onboarding progress.
2
+
3
+ Extracted from ``WorkspaceOSStore``. Owns the ``onboarding`` branch of the
4
+ state document: which named step the person is on, what each step recorded, and
5
+ whether the whole run is finished.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Optional
11
+
12
+ from .timeutil import now_iso as _now
13
+ from .workspace_os_constants import ONBOARDING_STEPS
14
+
15
+ __all__ = ["WorkspaceOnboarding", "ONBOARDING_STATUSES"]
16
+
17
+ #: The statuses a single step may hold. Anything else is a caller bug, not a
18
+ #: value to store — an unknown status would silently stall the flow, because
19
+ #: `current_step` only advances on `complete`/`skipped`.
20
+ ONBOARDING_STATUSES = frozenset({"pending", "running", "complete", "failed", "skipped"})
21
+
22
+
23
+ class WorkspaceOnboarding:
24
+ """Tracks the named first-run steps and where the person stopped."""
25
+
26
+ def __init__(self, store: Any) -> None:
27
+ self.store = store
28
+
29
+ def status(
30
+ self,
31
+ users: Optional[Dict[str, Any]] = None,
32
+ graph_stats: Optional[Dict[str, Any]] = None,
33
+ ) -> Dict[str, Any]:
34
+ state = self.store.load_state()
35
+ users = users or {}
36
+ admins = [
37
+ email for email, user in users.items()
38
+ if isinstance(user, dict) and user.get("role") == "admin"
39
+ ]
40
+ onboarding = state.get("onboarding") or {}
41
+ steps = onboarding.get("steps") or {}
42
+ return {
43
+ **onboarding,
44
+ "steps": [steps.get(step, {"id": step, "status": "pending"}) for step in ONBOARDING_STEPS],
45
+ "has_account": bool(users),
46
+ "has_admin": bool(admins) or bool(users),
47
+ "graph_ready": bool(graph_stats and not graph_stats.get("disabled")),
48
+ "required_steps": list(ONBOARDING_STEPS),
49
+ }
50
+
51
+ def update_step(
52
+ self,
53
+ step: str,
54
+ *,
55
+ status: str = "complete",
56
+ data: Optional[Dict[str, Any]] = None,
57
+ error: str = "",
58
+ user_email: Optional[str] = None,
59
+ ) -> Dict[str, Any]:
60
+ if step not in ONBOARDING_STEPS:
61
+ raise ValueError(f"unknown onboarding step: {step}")
62
+ if status not in ONBOARDING_STATUSES:
63
+ raise ValueError(f"unknown onboarding status: {status}")
64
+ state = self.store.load_state()
65
+ onboarding = state.setdefault("onboarding", {})
66
+ steps = onboarding.setdefault("steps", {})
67
+ record = steps.setdefault(step, {"id": step})
68
+ record.update({
69
+ "id": step,
70
+ "status": status,
71
+ "data": data or record.get("data") or {},
72
+ "error": error,
73
+ "updated_at": _now(),
74
+ "user_email": user_email,
75
+ })
76
+ if status in {"complete", "skipped"}:
77
+ index = ONBOARDING_STEPS.index(step)
78
+ if step == "complete":
79
+ onboarding["completed"] = True
80
+ onboarding["completed_at"] = _now()
81
+ onboarding["current_step"] = "complete"
82
+ elif index + 1 < len(ONBOARDING_STEPS):
83
+ onboarding["current_step"] = ONBOARDING_STEPS[index + 1]
84
+ elif status == "failed":
85
+ onboarding["current_step"] = step
86
+ self.store.save_state(state)
87
+ self.store.record_timeline_event(
88
+ "workspace", "onboarding_step", {"step": step, "status": status}
89
+ )
90
+ return self.status()
91
+
92
+ def complete(
93
+ self,
94
+ data: Optional[Dict[str, Any]] = None,
95
+ user_email: Optional[str] = None,
96
+ ) -> Dict[str, Any]:
97
+ for step in ONBOARDING_STEPS:
98
+ self.update_step(
99
+ step,
100
+ status="complete",
101
+ data=data if step == "complete" else None,
102
+ user_email=user_email,
103
+ )
104
+ return self.status()