ltcai 4.2.0 → 4.3.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.
- package/README.md +26 -21
- package/docs/CHANGELOG.md +42 -0
- package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +69 -0
- package/docs/V4_3_PRIVACY_AUDIT.md +60 -0
- package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +53 -0
- package/docs/V4_3_VALIDATION_REPORT.md +58 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +25 -25
- package/frontend/openapi.json +203 -1
- package/frontend/src/api/client.ts +7 -0
- package/frontend/src/api/openapi.ts +258 -0
- package/frontend/src/pages/System.tsx +58 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +360 -47
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +11 -0
- package/latticeai/api/portability.py +59 -2
- package/latticeai/app_factory.py +9 -0
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/product_hardening.py +217 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/kg_portability.py +147 -4
- package/ltcai_cli.py +2 -1
- package/package.json +3 -3
- package/scripts/clean_release_artifacts.mjs +27 -0
- package/scripts/lint_frontend.mjs +5 -0
- package/scripts/validate_release_artifacts.py +10 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/main.rs +113 -13
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/{index-C_HAkbAg.js → index-RiJTJliG.js} +3 -3
- package/static/app/assets/index-RiJTJliG.js.map +1 -0
- package/static/app/assets/{index-CDjiH_se.css → index-yZswHE3d.css} +1 -1
- package/static/app/index.html +2 -2
- package/static/app/assets/index-C_HAkbAg.js.map +0 -1
|
@@ -26,6 +26,8 @@ class BackupRequest(BaseModel):
|
|
|
26
26
|
class RestoreRequest(BaseModel):
|
|
27
27
|
path: str
|
|
28
28
|
verify: bool = True
|
|
29
|
+
dry_run: bool = False
|
|
30
|
+
confirm: bool = False
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
class EncryptedArchiveRequest(BaseModel):
|
|
@@ -36,6 +38,18 @@ class EncryptedArchiveRequest(BaseModel):
|
|
|
36
38
|
class EncryptedRestoreRequest(BaseModel):
|
|
37
39
|
path: str
|
|
38
40
|
passphrase: str
|
|
41
|
+
dry_run: bool = False
|
|
42
|
+
confirm: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EncryptedInspectRequest(BaseModel):
|
|
46
|
+
path: str
|
|
47
|
+
passphrase: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EncryptedVerifyRequest(BaseModel):
|
|
51
|
+
path: str
|
|
52
|
+
passphrase: str
|
|
39
53
|
|
|
40
54
|
|
|
41
55
|
class DockerPostgresRequest(BaseModel):
|
|
@@ -74,6 +88,12 @@ def create_portability_router(
|
|
|
74
88
|
_require_service()
|
|
75
89
|
return service.storage_status()
|
|
76
90
|
|
|
91
|
+
@router.get("/api/knowledge-graph/backup-health")
|
|
92
|
+
async def backup_health(request: Request):
|
|
93
|
+
require_user(request)
|
|
94
|
+
_require_service()
|
|
95
|
+
return service.backup_health()
|
|
96
|
+
|
|
77
97
|
@router.get("/api/knowledge-graph/provenance")
|
|
78
98
|
async def recent_provenance(request: Request, limit: int = 50, source_type: Optional[str] = None):
|
|
79
99
|
"""Recent ingestions (provenance trail) for the ingestion-sources UI."""
|
|
@@ -114,7 +134,7 @@ def create_portability_router(
|
|
|
114
134
|
require_admin(request)
|
|
115
135
|
_require_service()
|
|
116
136
|
try:
|
|
117
|
-
return service.restore(req.path, verify=req.verify)
|
|
137
|
+
return service.restore(req.path, verify=req.verify, dry_run=req.dry_run, confirm=req.confirm)
|
|
118
138
|
except (ValueError, FileNotFoundError) as exc:
|
|
119
139
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
120
140
|
|
|
@@ -127,12 +147,49 @@ def create_portability_router(
|
|
|
127
147
|
except (ValueError, FileNotFoundError) as exc:
|
|
128
148
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
129
149
|
|
|
150
|
+
@router.post("/api/knowledge-graph/archive/inspect")
|
|
151
|
+
async def inspect_encrypted_archive(req: EncryptedInspectRequest, request: Request):
|
|
152
|
+
require_admin(request)
|
|
153
|
+
_require_service()
|
|
154
|
+
try:
|
|
155
|
+
return service.inspect_encrypted_archive(req.path, passphrase=req.passphrase)
|
|
156
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
157
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
158
|
+
|
|
159
|
+
@router.post("/api/knowledge-graph/archive/verify")
|
|
160
|
+
async def verify_encrypted_archive(req: EncryptedVerifyRequest, request: Request):
|
|
161
|
+
require_admin(request)
|
|
162
|
+
_require_service()
|
|
163
|
+
result = service.verify_encrypted_archive(req.path, passphrase=req.passphrase)
|
|
164
|
+
if not result.get("ok"):
|
|
165
|
+
raise HTTPException(status_code=400, detail="; ".join(result.get("errors") or ["Archive verification failed."]))
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
@router.post("/api/knowledge-graph/archive/import")
|
|
169
|
+
async def import_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
|
|
170
|
+
require_admin(request)
|
|
171
|
+
_require_service()
|
|
172
|
+
try:
|
|
173
|
+
return service.import_encrypted_archive(
|
|
174
|
+
req.path,
|
|
175
|
+
passphrase=req.passphrase,
|
|
176
|
+
dry_run=req.dry_run,
|
|
177
|
+
confirm=req.confirm,
|
|
178
|
+
)
|
|
179
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
180
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
181
|
+
|
|
130
182
|
@router.post("/api/knowledge-graph/archive/restore")
|
|
131
183
|
async def restore_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
|
|
132
184
|
require_admin(request)
|
|
133
185
|
_require_service()
|
|
134
186
|
try:
|
|
135
|
-
return service.restore_encrypted_archive(
|
|
187
|
+
return service.restore_encrypted_archive(
|
|
188
|
+
req.path,
|
|
189
|
+
passphrase=req.passphrase,
|
|
190
|
+
dry_run=req.dry_run,
|
|
191
|
+
confirm=req.confirm,
|
|
192
|
+
)
|
|
136
193
|
except (ValueError, FileNotFoundError) as exc:
|
|
137
194
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
138
195
|
|
package/latticeai/app_factory.py
CHANGED
|
@@ -154,6 +154,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
154
154
|
from latticeai.api.hooks import create_hooks_router
|
|
155
155
|
from latticeai.core.hooks import HooksRegistry
|
|
156
156
|
from latticeai.core.builtin_hooks import register_builtin_hook_runners
|
|
157
|
+
from latticeai.core.product_hardening import build_product_hardening_status
|
|
157
158
|
from latticeai.api.agent_registry import create_agent_registry_router
|
|
158
159
|
from latticeai.core.agent_registry import AgentRegistry
|
|
159
160
|
from latticeai.api.memory import create_memory_router
|
|
@@ -1256,6 +1257,13 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1256
1257
|
except Exception as e:
|
|
1257
1258
|
return {"error": str(e)}
|
|
1258
1259
|
|
|
1260
|
+
def _product_hardening_status():
|
|
1261
|
+
return build_product_hardening_status(
|
|
1262
|
+
config=CONFIG,
|
|
1263
|
+
portability=KG_PORTABILITY,
|
|
1264
|
+
device_identity=DEVICE_IDENTITY,
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1259
1267
|
app.include_router(create_admin_router(
|
|
1260
1268
|
require_admin=require_admin, require_user=require_user,
|
|
1261
1269
|
load_users=load_users, save_users=save_users,
|
|
@@ -1270,6 +1278,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1270
1278
|
invite_code=INVITE_CODE, invite_gate_enabled=INVITE_GATE_ENABLED,
|
|
1271
1279
|
default_port=DEFAULT_PORT,
|
|
1272
1280
|
policy_matrix=policy_matrix,
|
|
1281
|
+
product_hardening_status=_product_hardening_status,
|
|
1273
1282
|
))
|
|
1274
1283
|
|
|
1275
1284
|
app.include_router(create_invitations_router(
|
package/latticeai/core/config.py
CHANGED
|
@@ -163,7 +163,7 @@ class Config:
|
|
|
163
163
|
host=host,
|
|
164
164
|
port=port,
|
|
165
165
|
network_exposed=network_exposed,
|
|
166
|
-
enable_telegram=_bool(env, "LATTICEAI_ENABLE_TELEGRAM", default=
|
|
166
|
+
enable_telegram=_bool(env, "LATTICEAI_ENABLE_TELEGRAM", default=False),
|
|
167
167
|
enable_graph=_bool(env, "LATTICEAI_ENABLE_GRAPH", default=True),
|
|
168
168
|
autoload_models=_bool(env, "LATTICEAI_AUTOLOAD_MODELS", default=is_public),
|
|
169
169
|
model_idle_unload_seconds=_int(env, "LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", 0),
|
|
@@ -14,7 +14,7 @@ from datetime import datetime
|
|
|
14
14
|
from typing import Any, Callable, Dict, List, Optional
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
MULTI_AGENT_VERSION = "4.
|
|
17
|
+
MULTI_AGENT_VERSION = "4.3.0"
|
|
18
18
|
|
|
19
19
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
20
20
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Product hardening and privacy status helpers.
|
|
2
|
+
|
|
3
|
+
These helpers are read-only and must not perform network probes. They describe
|
|
4
|
+
the local-first startup posture and distinguish available credentials from
|
|
5
|
+
enabled outbound communication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Mapping, Optional
|
|
14
|
+
|
|
15
|
+
from latticeai.core.config import Config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _bool(env: Mapping[str, str], key: str, default: bool = False) -> bool:
|
|
19
|
+
raw = env.get(key)
|
|
20
|
+
if raw is None:
|
|
21
|
+
return default
|
|
22
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _present(env: Mapping[str, str], *keys: str) -> bool:
|
|
26
|
+
return any(bool(str(env.get(key) or "").strip()) for key in keys)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def external_integration_status(
|
|
30
|
+
config: Config,
|
|
31
|
+
*,
|
|
32
|
+
env: Optional[Mapping[str, str]] = None,
|
|
33
|
+
) -> Dict[str, Any]:
|
|
34
|
+
if env is None:
|
|
35
|
+
env = os.environ
|
|
36
|
+
telegram_credentials = _present(env, "LATTICEAI_TELEGRAM_BOT_TOKEN", "TELEGRAM_BOT_TOKEN")
|
|
37
|
+
brain_network_auto_push = _bool(env, "LATTICEAI_BRAIN_NETWORK_AUTO_PUSH", default=False)
|
|
38
|
+
updater_enabled = _bool(env, "LATTICEAI_ENABLE_UPDATES", default=False)
|
|
39
|
+
model_downloads_enabled = _bool(env, "LATTICEAI_ALLOW_MODEL_DOWNLOADS", default=False) or bool(config.autoload_models)
|
|
40
|
+
docker_auto_start = _bool(env, "LATTICEAI_DOCKER_AUTO_START", default=False)
|
|
41
|
+
external_connectors_enabled = _bool(env, "LATTICEAI_ENABLE_EXTERNAL_CONNECTORS", default=False)
|
|
42
|
+
postgres_enabled = config.storage_engine == "postgres" and bool(config.postgres_dsn)
|
|
43
|
+
return {
|
|
44
|
+
"local_only_default": default_startup_local_only(config, env=env),
|
|
45
|
+
"integrations": {
|
|
46
|
+
"telegram": {
|
|
47
|
+
"enabled": bool(config.enable_telegram),
|
|
48
|
+
"credential_present": telegram_credentials,
|
|
49
|
+
"opt_in_required": True,
|
|
50
|
+
"automatic_egress": bool(config.enable_telegram),
|
|
51
|
+
"detail": (
|
|
52
|
+
"enabled by LATTICEAI_ENABLE_TELEGRAM"
|
|
53
|
+
if config.enable_telegram
|
|
54
|
+
else "disabled; token presence alone does not start Telegram"
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
"brain_network": {
|
|
58
|
+
"enabled": brain_network_auto_push,
|
|
59
|
+
"credential_present": False,
|
|
60
|
+
"opt_in_required": True,
|
|
61
|
+
"automatic_egress": brain_network_auto_push,
|
|
62
|
+
"detail": "peer pushes are user/admin initiated; no automatic peer sync by default",
|
|
63
|
+
},
|
|
64
|
+
"updates": {
|
|
65
|
+
"enabled": updater_enabled,
|
|
66
|
+
"credential_present": False,
|
|
67
|
+
"opt_in_required": True,
|
|
68
|
+
"automatic_egress": updater_enabled,
|
|
69
|
+
"detail": "desktop updater checks are disabled unless LATTICEAI_ENABLE_UPDATES is true",
|
|
70
|
+
},
|
|
71
|
+
"model_downloads": {
|
|
72
|
+
"enabled": model_downloads_enabled,
|
|
73
|
+
"credential_present": _present(env, "HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN"),
|
|
74
|
+
"opt_in_required": True,
|
|
75
|
+
"automatic_egress": bool(config.autoload_models),
|
|
76
|
+
"detail": "model downloads require an explicit load/autoload setting",
|
|
77
|
+
},
|
|
78
|
+
"docker": {
|
|
79
|
+
"enabled": docker_auto_start,
|
|
80
|
+
"credential_present": False,
|
|
81
|
+
"opt_in_required": True,
|
|
82
|
+
"automatic_egress": docker_auto_start,
|
|
83
|
+
"detail": "Docker setup requires explicit runtime consent; auto-start is disabled by default",
|
|
84
|
+
},
|
|
85
|
+
"postgres": {
|
|
86
|
+
"enabled": postgres_enabled,
|
|
87
|
+
"credential_present": bool(config.postgres_dsn),
|
|
88
|
+
"opt_in_required": True,
|
|
89
|
+
"automatic_egress": postgres_enabled,
|
|
90
|
+
"detail": "Postgres scale mode is used only when storage engine and DSN are explicitly configured",
|
|
91
|
+
},
|
|
92
|
+
"external_connectors": {
|
|
93
|
+
"enabled": external_connectors_enabled,
|
|
94
|
+
"credential_present": _present(
|
|
95
|
+
env,
|
|
96
|
+
"OPENAI_API_KEY",
|
|
97
|
+
"ANTHROPIC_API_KEY",
|
|
98
|
+
"GITHUB_TOKEN",
|
|
99
|
+
"SLACK_BOT_TOKEN",
|
|
100
|
+
"DISCORD_BOT_TOKEN",
|
|
101
|
+
),
|
|
102
|
+
"opt_in_required": True,
|
|
103
|
+
"automatic_egress": external_connectors_enabled,
|
|
104
|
+
"detail": "connector credentials are inert until the connector is explicitly enabled and invoked",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def default_startup_local_only(
|
|
111
|
+
config: Config,
|
|
112
|
+
*,
|
|
113
|
+
env: Optional[Mapping[str, str]] = None,
|
|
114
|
+
) -> bool:
|
|
115
|
+
if env is None:
|
|
116
|
+
env = os.environ
|
|
117
|
+
local_embedding = config.embedding_provider in {"", "hash", "local", "fallback", "sqlite"}
|
|
118
|
+
external = external_integration_status_no_recurse(config, env=env)
|
|
119
|
+
return (
|
|
120
|
+
not config.network_exposed
|
|
121
|
+
and not config.cors_allow_network
|
|
122
|
+
and not config.enable_telegram
|
|
123
|
+
and not config.autoload_models
|
|
124
|
+
and config.storage_engine == "sqlite"
|
|
125
|
+
and local_embedding
|
|
126
|
+
and not any(item["automatic_egress"] for item in external.values())
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def external_integration_status_no_recurse(
|
|
131
|
+
config: Config,
|
|
132
|
+
*,
|
|
133
|
+
env: Mapping[str, str],
|
|
134
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
135
|
+
return {
|
|
136
|
+
"brain_network": {"automatic_egress": _bool(env, "LATTICEAI_BRAIN_NETWORK_AUTO_PUSH", default=False)},
|
|
137
|
+
"updates": {"automatic_egress": _bool(env, "LATTICEAI_ENABLE_UPDATES", default=False)},
|
|
138
|
+
"docker": {"automatic_egress": _bool(env, "LATTICEAI_DOCKER_AUTO_START", default=False)},
|
|
139
|
+
"postgres": {"automatic_egress": config.storage_engine == "postgres" and bool(config.postgres_dsn)},
|
|
140
|
+
"external_connectors": {"automatic_egress": _bool(env, "LATTICEAI_ENABLE_EXTERNAL_CONNECTORS", default=False)},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_product_hardening_status(
|
|
145
|
+
*,
|
|
146
|
+
config: Config,
|
|
147
|
+
portability: Any = None,
|
|
148
|
+
device_identity: Any = None,
|
|
149
|
+
env: Optional[Mapping[str, str]] = None,
|
|
150
|
+
) -> Dict[str, Any]:
|
|
151
|
+
if env is None:
|
|
152
|
+
env = os.environ
|
|
153
|
+
storage = {"available": False}
|
|
154
|
+
backup = {"available": False}
|
|
155
|
+
if portability is not None and getattr(portability, "available", lambda: False)():
|
|
156
|
+
storage = portability.storage_status()
|
|
157
|
+
backup = portability.backup_health()
|
|
158
|
+
identity = {}
|
|
159
|
+
if device_identity is not None:
|
|
160
|
+
identity = device_identity.describe()
|
|
161
|
+
data_dir = Path(config.data_dir)
|
|
162
|
+
return {
|
|
163
|
+
"version": "4.3.0",
|
|
164
|
+
"startup": {
|
|
165
|
+
"local_only_default": default_startup_local_only(config, env=env),
|
|
166
|
+
"host": config.host,
|
|
167
|
+
"port": config.port,
|
|
168
|
+
"network_exposed": config.network_exposed,
|
|
169
|
+
"auth_required": config.require_auth,
|
|
170
|
+
"cors_network_allowed": config.cors_allow_network,
|
|
171
|
+
},
|
|
172
|
+
"desktop": {
|
|
173
|
+
"sidecar_lifecycle": "managed",
|
|
174
|
+
"restart_supported": True,
|
|
175
|
+
"shutdown_supported": True,
|
|
176
|
+
"updater": {
|
|
177
|
+
"enabled": _bool(env, "LATTICEAI_ENABLE_UPDATES", default=False),
|
|
178
|
+
"limitation": "No external update checks run unless explicitly enabled by policy.",
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
"first_run": {
|
|
182
|
+
"data_dir": str(data_dir),
|
|
183
|
+
"data_dir_exists": data_dir.exists(),
|
|
184
|
+
"python_available": shutil.which("python3") is not None or shutil.which("python") is not None,
|
|
185
|
+
"docker_available": shutil.which("docker") is not None,
|
|
186
|
+
"docker_required": False,
|
|
187
|
+
"postgres_required": False,
|
|
188
|
+
},
|
|
189
|
+
"privacy": external_integration_status(config, env=env),
|
|
190
|
+
"storage": storage,
|
|
191
|
+
"backup": backup,
|
|
192
|
+
"device_identity": identity,
|
|
193
|
+
"permissions": {
|
|
194
|
+
"export_requires_admin": True,
|
|
195
|
+
"import_requires_admin": True,
|
|
196
|
+
"restore_requires_admin": True,
|
|
197
|
+
"destructive_restore_requires_confirmation": True,
|
|
198
|
+
"workspace_isolation_enforced": True,
|
|
199
|
+
"audit_log_visible_to_admin": True,
|
|
200
|
+
},
|
|
201
|
+
"failure_policy": {
|
|
202
|
+
"archive_corruption": "fail_closed",
|
|
203
|
+
"partial_archive": "fail_closed",
|
|
204
|
+
"signature_mismatch": "fail_closed",
|
|
205
|
+
"unsupported_version": "fail_closed",
|
|
206
|
+
"missing_docker": "honest_unavailable",
|
|
207
|
+
"missing_postgres": "honest_unavailable",
|
|
208
|
+
"permission_denied": "honest_error",
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
__all__ = [
|
|
214
|
+
"build_product_hardening_status",
|
|
215
|
+
"default_startup_local_only",
|
|
216
|
+
"external_integration_status",
|
|
217
|
+
]
|
|
@@ -19,7 +19,7 @@ from pathlib import Path
|
|
|
19
19
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
WORKSPACE_OS_VERSION = "4.
|
|
22
|
+
WORKSPACE_OS_VERSION = "4.3.0"
|
|
23
23
|
|
|
24
24
|
# Workspace types separate single-user Personal workspaces from shared
|
|
25
25
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -19,7 +19,7 @@ import shutil
|
|
|
19
19
|
import tempfile
|
|
20
20
|
import zipfile
|
|
21
21
|
from datetime import datetime, timezone
|
|
22
|
-
from pathlib import Path
|
|
22
|
+
from pathlib import Path, PurePosixPath
|
|
23
23
|
from typing import Any, Dict, Optional
|
|
24
24
|
|
|
25
25
|
from lattice_brain.archive import BrainArchivePaths, EncryptedBrainArchive
|
|
@@ -50,6 +50,13 @@ def _sha256_file(path: Path) -> str:
|
|
|
50
50
|
return h.hexdigest()
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _safe_zip_names(names) -> None:
|
|
54
|
+
for name in names:
|
|
55
|
+
path = PurePosixPath(name)
|
|
56
|
+
if path.is_absolute() or ".." in path.parts:
|
|
57
|
+
raise ValueError(f"Backup archive contains unsafe path: {name}")
|
|
58
|
+
|
|
59
|
+
|
|
53
60
|
class KGPortabilityService:
|
|
54
61
|
def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
|
|
55
62
|
self._kg = knowledge_graph
|
|
@@ -156,13 +163,23 @@ class KGPortabilityService:
|
|
|
156
163
|
zf.write(f, f"blobs/{f.relative_to(blob_dir)}")
|
|
157
164
|
return {"path": str(dest), "bytes": dest.stat().st_size, "manifest": manifest}
|
|
158
165
|
|
|
159
|
-
def restore(
|
|
166
|
+
def restore(
|
|
167
|
+
self,
|
|
168
|
+
archive_path,
|
|
169
|
+
*,
|
|
170
|
+
verify: bool = True,
|
|
171
|
+
dry_run: bool = False,
|
|
172
|
+
confirm: bool = False,
|
|
173
|
+
) -> Dict[str, Any]:
|
|
160
174
|
self._require()
|
|
161
175
|
archive = Path(archive_path)
|
|
162
176
|
if not archive.exists():
|
|
163
177
|
raise FileNotFoundError(f"Backup archive not found: {archive}")
|
|
178
|
+
if not dry_run and not confirm:
|
|
179
|
+
raise ValueError("Explicit confirmation is required before restoring a Knowledge Graph backup.")
|
|
164
180
|
with zipfile.ZipFile(archive) as zf:
|
|
165
181
|
names = zf.namelist()
|
|
182
|
+
_safe_zip_names(names)
|
|
166
183
|
if "knowledge_graph.sqlite" not in names:
|
|
167
184
|
raise ValueError("Archive is missing knowledge_graph.sqlite.")
|
|
168
185
|
manifest = json.loads(zf.read("manifest.json")) if "manifest.json" in names else {}
|
|
@@ -173,6 +190,18 @@ class KGPortabilityService:
|
|
|
173
190
|
if verify and manifest.get("db_sha256"):
|
|
174
191
|
if _sha256_file(db_src) != manifest["db_sha256"]:
|
|
175
192
|
raise ValueError("Backup integrity check failed (db sha256 mismatch).")
|
|
193
|
+
if dry_run:
|
|
194
|
+
return {
|
|
195
|
+
"restored": False,
|
|
196
|
+
"dry_run": True,
|
|
197
|
+
"verified": True,
|
|
198
|
+
"manifest": manifest,
|
|
199
|
+
"planned": {
|
|
200
|
+
"database": str(self._kg.db_path),
|
|
201
|
+
"blobs": str(self._kg.blob_dir),
|
|
202
|
+
"archive": str(archive),
|
|
203
|
+
},
|
|
204
|
+
}
|
|
176
205
|
db_dest = Path(self._kg.db_path)
|
|
177
206
|
blob_dest = Path(self._kg.blob_dir)
|
|
178
207
|
db_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -196,25 +225,82 @@ class KGPortabilityService:
|
|
|
196
225
|
"nodes": sum(stats.get("nodes", {}).values()),
|
|
197
226
|
}
|
|
198
227
|
|
|
228
|
+
def verify_backup(self, archive_path) -> Dict[str, Any]:
|
|
229
|
+
archive = Path(archive_path)
|
|
230
|
+
if not archive.exists():
|
|
231
|
+
return {"ok": False, "path": str(archive), "errors": [f"Backup archive not found: {archive}"]}
|
|
232
|
+
try:
|
|
233
|
+
with zipfile.ZipFile(archive) as zf:
|
|
234
|
+
names = zf.namelist()
|
|
235
|
+
_safe_zip_names(names)
|
|
236
|
+
if "knowledge_graph.sqlite" not in names:
|
|
237
|
+
raise ValueError("Archive is missing knowledge_graph.sqlite.")
|
|
238
|
+
manifest = json.loads(zf.read("manifest.json")) if "manifest.json" in names else {}
|
|
239
|
+
with tempfile.TemporaryDirectory() as tmp_s:
|
|
240
|
+
tmp = Path(tmp_s)
|
|
241
|
+
zf.extract("knowledge_graph.sqlite", tmp)
|
|
242
|
+
db_src = tmp / "knowledge_graph.sqlite"
|
|
243
|
+
if manifest.get("db_sha256") and _sha256_file(db_src) != manifest["db_sha256"]:
|
|
244
|
+
raise ValueError("Backup integrity check failed (db sha256 mismatch).")
|
|
245
|
+
return {"ok": True, "path": str(archive), "manifest": manifest, "errors": []}
|
|
246
|
+
except (ValueError, zipfile.BadZipFile, OSError, json.JSONDecodeError) as exc:
|
|
247
|
+
return {"ok": False, "path": str(archive), "errors": [str(exc)]}
|
|
248
|
+
|
|
199
249
|
# ── encrypted .latticebrain archive ───────────────────────────────────
|
|
200
250
|
def encrypted_archive(self, dest_path=None, *, passphrase: str) -> Dict[str, Any]:
|
|
201
251
|
self._require()
|
|
202
252
|
self._exports_dir.mkdir(parents=True, exist_ok=True)
|
|
203
253
|
dest = Path(dest_path) if dest_path else self._exports_dir / f"brain-{_stamp()}.latticebrain"
|
|
254
|
+
metadata = {
|
|
255
|
+
"storage": self.storage_status().get("active", {}),
|
|
256
|
+
"snapshot": self.snapshot_metadata(),
|
|
257
|
+
"device_identity": self._identity.describe() if self._identity is not None else {},
|
|
258
|
+
"provenance": {"exported_at": _now_iso(), "source": "kg-portability"},
|
|
259
|
+
}
|
|
204
260
|
archive = EncryptedBrainArchive(
|
|
205
261
|
BrainArchivePaths(
|
|
206
262
|
db_path=Path(self._kg.db_path),
|
|
207
263
|
blob_dir=Path(self._kg.blob_dir),
|
|
264
|
+
data_dir=self._data_dir,
|
|
265
|
+
metadata=metadata,
|
|
208
266
|
)
|
|
209
267
|
)
|
|
210
268
|
return archive.create(dest, passphrase=passphrase)
|
|
211
269
|
|
|
212
|
-
def
|
|
270
|
+
def inspect_encrypted_archive(self, archive_path, *, passphrase: Optional[str] = None) -> Dict[str, Any]:
|
|
271
|
+
archive = EncryptedBrainArchive(
|
|
272
|
+
BrainArchivePaths(
|
|
273
|
+
db_path=Path(self._kg.db_path),
|
|
274
|
+
blob_dir=Path(self._kg.blob_dir),
|
|
275
|
+
data_dir=self._data_dir,
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
return archive.inspect(Path(archive_path), passphrase=passphrase)
|
|
279
|
+
|
|
280
|
+
def verify_encrypted_archive(self, archive_path, *, passphrase: str) -> Dict[str, Any]:
|
|
281
|
+
archive = EncryptedBrainArchive(
|
|
282
|
+
BrainArchivePaths(
|
|
283
|
+
db_path=Path(self._kg.db_path),
|
|
284
|
+
blob_dir=Path(self._kg.blob_dir),
|
|
285
|
+
data_dir=self._data_dir,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
return archive.verify(Path(archive_path), passphrase=passphrase)
|
|
289
|
+
|
|
290
|
+
def restore_encrypted_archive(
|
|
291
|
+
self,
|
|
292
|
+
archive_path,
|
|
293
|
+
*,
|
|
294
|
+
passphrase: str,
|
|
295
|
+
dry_run: bool = False,
|
|
296
|
+
confirm: bool = False,
|
|
297
|
+
) -> Dict[str, Any]:
|
|
213
298
|
self._require()
|
|
214
299
|
archive = EncryptedBrainArchive(
|
|
215
300
|
BrainArchivePaths(
|
|
216
301
|
db_path=Path(self._kg.db_path),
|
|
217
302
|
blob_dir=Path(self._kg.blob_dir),
|
|
303
|
+
data_dir=self._data_dir,
|
|
218
304
|
)
|
|
219
305
|
)
|
|
220
306
|
return archive.restore(
|
|
@@ -223,9 +309,29 @@ class KGPortabilityService:
|
|
|
223
309
|
target=BrainArchivePaths(
|
|
224
310
|
db_path=Path(self._kg.db_path),
|
|
225
311
|
blob_dir=Path(self._kg.blob_dir),
|
|
312
|
+
data_dir=self._data_dir,
|
|
226
313
|
),
|
|
314
|
+
dry_run=dry_run,
|
|
315
|
+
confirm=confirm,
|
|
227
316
|
)
|
|
228
317
|
|
|
318
|
+
def import_encrypted_archive(
|
|
319
|
+
self,
|
|
320
|
+
archive_path,
|
|
321
|
+
*,
|
|
322
|
+
passphrase: str,
|
|
323
|
+
dry_run: bool = False,
|
|
324
|
+
confirm: bool = False,
|
|
325
|
+
) -> Dict[str, Any]:
|
|
326
|
+
result = self.restore_encrypted_archive(
|
|
327
|
+
archive_path,
|
|
328
|
+
passphrase=passphrase,
|
|
329
|
+
dry_run=dry_run,
|
|
330
|
+
confirm=confirm,
|
|
331
|
+
)
|
|
332
|
+
result["operation"] = "import"
|
|
333
|
+
return result
|
|
334
|
+
|
|
229
335
|
# ── status surface ───────────────────────────────────────────────────────
|
|
230
336
|
def snapshot_metadata(self) -> Dict[str, Any]:
|
|
231
337
|
if not self.available():
|
|
@@ -253,6 +359,28 @@ class KGPortabilityService:
|
|
|
253
359
|
else {"engine": "sqlite", "available": True}
|
|
254
360
|
),
|
|
255
361
|
"postgres": PostgresEngine("", schema="lattice_brain").capabilities().as_dict(),
|
|
362
|
+
"backup_health": self.backup_health(),
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
def backup_health(self) -> Dict[str, Any]:
|
|
366
|
+
self._exports_dir.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
backups = sorted(
|
|
368
|
+
[
|
|
369
|
+
p for p in self._exports_dir.glob("*")
|
|
370
|
+
if p.is_file() and (p.suffix == ".zip" or p.suffix == ".latticebrain")
|
|
371
|
+
],
|
|
372
|
+
key=lambda p: p.stat().st_mtime,
|
|
373
|
+
reverse=True,
|
|
374
|
+
)
|
|
375
|
+
latest = backups[0] if backups else None
|
|
376
|
+
return {
|
|
377
|
+
"available": True,
|
|
378
|
+
"directory": str(self._exports_dir),
|
|
379
|
+
"count": len(backups),
|
|
380
|
+
"latest": str(latest) if latest else None,
|
|
381
|
+
"latest_bytes": latest.stat().st_size if latest else 0,
|
|
382
|
+
"encrypted_archives": sum(1 for p in backups if p.suffix == ".latticebrain"),
|
|
383
|
+
"zip_backups": sum(1 for p in backups if p.suffix == ".zip"),
|
|
256
384
|
}
|
|
257
385
|
|
|
258
386
|
def postgres_docker_setup(
|
|
@@ -279,7 +407,22 @@ class KGPortabilityService:
|
|
|
279
407
|
Path(self._kg.db_path),
|
|
280
408
|
PostgresEngine(dsn, schema=schema),
|
|
281
409
|
)
|
|
282
|
-
|
|
410
|
+
if dry_run:
|
|
411
|
+
return migrator.migrate(dry_run=True)
|
|
412
|
+
backup = self.backup()
|
|
413
|
+
verification = self.verify_backup(backup["path"])
|
|
414
|
+
if not verification.get("ok"):
|
|
415
|
+
raise RuntimeError(
|
|
416
|
+
"Pre-migration backup verification failed; Postgres migration was not started: "
|
|
417
|
+
+ "; ".join(verification.get("errors") or [])
|
|
418
|
+
)
|
|
419
|
+
result = migrator.migrate(dry_run=False)
|
|
420
|
+
result["pre_migration_backup"] = {
|
|
421
|
+
"path": backup["path"],
|
|
422
|
+
"verified": True,
|
|
423
|
+
"manifest": backup.get("manifest"),
|
|
424
|
+
}
|
|
425
|
+
return result
|
|
283
426
|
|
|
284
427
|
def recent_ingestions(self, *, limit: int = 50, source_type: Optional[str] = None) -> Dict[str, Any]:
|
|
285
428
|
"""Recent provenance records (newest first) for the ingestion-sources UI."""
|
package/ltcai_cli.py
CHANGED
|
@@ -269,9 +269,10 @@ def main() -> None:
|
|
|
269
269
|
|
|
270
270
|
# Telegram startup notification (local start, tunnel handled separately inside _start_tunnel)
|
|
271
271
|
if not args.tunnel:
|
|
272
|
+
_tg_enabled = os.getenv("LATTICEAI_ENABLE_TELEGRAM", "").strip().lower() in ("1", "true", "yes", "on")
|
|
272
273
|
_tg_token = os.getenv("LATTICEAI_TELEGRAM_BOT_TOKEN", "")
|
|
273
274
|
_tg_chat = os.getenv("LATTICEAI_TELEGRAM_CHAT_ID", "")
|
|
274
|
-
if _tg_token and _tg_chat:
|
|
275
|
+
if _tg_enabled and _tg_token and _tg_chat:
|
|
275
276
|
_local_msg = (
|
|
276
277
|
f"✅ Lattice AI 시작됨\n\n"
|
|
277
278
|
f"🏠 로컬: http://localhost:{args.port}"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "Lattice AI — local-first Digital Brain Platform (knowledge graph, durable memory, hybrid search, agents, signed brain exchange)",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"desktop:tauri:check": "cd src-tauri && cargo check",
|
|
42
42
|
"desktop:electron": "electron desktop/electron/main.cjs",
|
|
43
43
|
"package:vsix": "node scripts/build_vsix.mjs",
|
|
44
|
-
"release:artifacts": "npm run build:assets && npm run build:python && npm pack && npm run package:vsix",
|
|
45
|
-
"release:validate": "node scripts/run_python.mjs scripts/validate_release_artifacts.py $npm_package_version --require-vsix --require-tgz",
|
|
44
|
+
"release:artifacts": "node scripts/clean_release_artifacts.mjs $npm_package_version && npm run build:assets && npm run build:python && npm pack && npm run package:vsix && npm run desktop:tauri:build",
|
|
45
|
+
"release:validate": "node scripts/run_python.mjs scripts/validate_release_artifacts.py $npm_package_version --require-vsix --require-tgz --require-dmg",
|
|
46
46
|
"publish:npm": "npm pack && npm publish ltcai-$npm_package_version.tgz --access public",
|
|
47
47
|
"publish:pypi": "npm run build:python && node scripts/run_python.mjs -m twine upload --skip-existing dist/ltcai-$npm_package_version.tar.gz dist/ltcai-$npm_package_version-py3-none-any.whl",
|
|
48
48
|
"publish:vscode": "cd vscode-extension && npm run package:vsix && npm run publish:vscode",
|