ltcai 10.5.0 → 10.6.2
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 +62 -45
- package/docs/CHANGELOG.md +212 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_contract.py +15 -0
- package/lattice_brain/graph/provenance.py +61 -5
- package/lattice_brain/graph/retrieval.py +16 -4
- package/lattice_brain/graph/retrieval_reads.py +136 -23
- package/lattice_brain/graph/retrieval_vector.py +156 -8
- package/lattice_brain/ingestion.py +14 -1
- package/lattice_brain/ingestion_jobs.py +255 -5
- package/lattice_brain/portability.py +5 -2
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/browser.py +8 -14
- package/latticeai/api/knowledge_graph.py +46 -16
- package/latticeai/api/workspace.py +4 -11
- package/latticeai/api/workspace_scope.py +125 -0
- package/latticeai/core/agent.py +68 -3
- package/latticeai/core/config.py +6 -0
- package/latticeai/core/csrf.py +293 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/models/router.py +61 -16
- package/latticeai/runtime/build_phases.py +2 -0
- package/latticeai/runtime/config_runtime.py +2 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/runtime/web_runtime.py +31 -4
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +9 -1
- package/latticeai/services/tool_dispatch.py +13 -7
- package/latticeai/tools/__init__.py +1 -0
- package/latticeai/tools/documents.py +44 -13
- package/package.json +1 -1
- package/scripts/build_frontend_assets.mjs +21 -4
- package/scripts/capture_release_evidence.mjs +13 -5
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_frontend_build_freshness.mjs +170 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +40 -38
- package/static/app/assets/{Act-CLsUUE4V.js → Act-aNud-lKL.js} +1 -1
- package/static/app/assets/AdminConsole-QiCTH68K.js +1 -0
- package/static/app/assets/Brain-CMFh5q6k.js +321 -0
- package/static/app/assets/{BrainHome-DMMhKb8j.js → BrainHome-B4Ar3atu.js} +2 -2
- package/static/app/assets/{BrainSignals-CcvYpBGn.js → BrainSignals-RVMlmTGC.js} +1 -1
- package/static/app/assets/Capture-DEBo-0vZ.js +1 -0
- package/static/app/assets/CommandPalette-bW5WxWWZ.js +1 -0
- package/static/app/assets/Library-5gFexm83.js +1 -0
- package/static/app/assets/{LivingBrain-BVFU-llN.js → LivingBrain-FlHFfu9i.js} +1 -1
- package/static/app/assets/ProductFlow-CFUNDOHu.js +1 -0
- package/static/app/assets/{ReviewCard-DODh3z_D.js → ReviewCard-BHj86h2Z.js} +2 -2
- package/static/app/assets/System-CeNHoZRu.js +1 -0
- package/static/app/assets/{activity-DYlZpTrt.js → activity-7_ZmZqN0.js} +1 -1
- package/static/app/assets/arrow-left-Dv2Tiwhe.js +1 -0
- package/static/app/assets/{bot-BBmfPYKv.js → bot-D1gX4xks.js} +1 -1
- package/static/app/assets/brain-Dn_bDfl4.js +1 -0
- package/static/app/assets/{button-DXSLgXUl.js → button-DPFZ9lGw.js} +1 -1
- package/static/app/assets/{circle-pause-B8TPRvnt.js → circle-pause-B2P72YOO.js} +1 -1
- package/static/app/assets/{circle-play-C5aQnYUv.js → circle-play-BiC8z2vg.js} +1 -1
- package/static/app/assets/{cpu-BvOUS0hg.js → cpu-Cx-wRR_V.js} +1 -1
- package/static/app/assets/{download-BM2Y4baa.js → download-CKxlzxsK.js} +1 -1
- package/static/app/assets/{folder-open-DATq5giq.js → folder-open-Bbjju0tt.js} +1 -1
- package/static/app/assets/{hard-drive-D2iumtyP.js → hard-drive-Bon1VBvq.js} +1 -1
- package/static/app/assets/index-DYUs0cWy.css +2 -0
- package/static/app/assets/index-mLP0-YNO.js +10 -0
- package/static/app/assets/{input-Ptpha52f.js → input-DrMc0Xns.js} +1 -1
- package/static/app/assets/{permissionCopy-DLSOKW1h.js → permissionCopy-BGUsI7vw.js} +1 -1
- package/static/app/assets/{primitives-BvxTiz4R.js → primitives-CEMTjBz1.js} +1 -1
- package/static/app/assets/search-HsIji1wY.js +1 -0
- package/static/app/assets/share-2-D7THHq5K.js +1 -0
- package/static/app/assets/{shield-alert-B6RHxSXi.js → shield-alert-BoU4_8r9.js} +1 -1
- package/static/app/assets/{textarea-CCZqVitr.js → textarea-rU1Lb7ia.js} +1 -1
- package/static/app/assets/{useFocusTrap-QR9BiUDX.js → useFocusTrap-BgvK4Nkx.js} +1 -1
- package/static/app/assets/{useQuery-B42HOaUm.js → useQuery-CT2ChyuU.js} +1 -1
- package/static/app/assets/{users-D9PLGmAE.js → users-DlbfHBQV.js} +1 -1
- package/static/app/assets/{utils-CAuwfT8i.js → utils-fEGWreKB.js} +1 -1
- package/static/app/assets/workspace-ClDBz_0f.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/AdminConsole-D1PBR5Mk.js +0 -1
- package/static/app/assets/Brain-Cnb_YCYH.js +0 -321
- package/static/app/assets/Capture-DU1HZWqB.js +0 -1
- package/static/app/assets/CommandPalette-tU229JbI.js +0 -1
- package/static/app/assets/Library-BeutJd8j.js +0 -1
- package/static/app/assets/ProductFlow-CZp8oHh_.js +0 -1
- package/static/app/assets/System-CTYfZXfv.js +0 -1
- package/static/app/assets/brain-RRpEg40e.js +0 -1
- package/static/app/assets/index-B1n87vO-.css +0 -2
- package/static/app/assets/index-D10Yd0gh.js +0 -10
- package/static/app/assets/search-DtMikKf0.js +0 -1
- package/static/app/assets/workspace-DT2v1d3T.js +0 -1
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
"""FastAPI shell assembly for the application factory.
|
|
2
2
|
|
|
3
|
-
This seam owns only the outer web shell: app construction,
|
|
4
|
-
and static asset mounts. Router
|
|
5
|
-
the route snapshot/reorder step is
|
|
3
|
+
This seam owns only the outer web shell: app construction, the request-side
|
|
4
|
+
middleware (CSRF origin guard, CORS), and static asset mounts. Router
|
|
5
|
+
registration stays in ``app_factory`` until the route snapshot/reorder step is
|
|
6
|
+
reviewed.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from __future__ import annotations
|
|
9
10
|
|
|
10
11
|
from pathlib import Path
|
|
11
|
-
from typing import Any, Dict, Iterable
|
|
12
|
+
from typing import Any, Dict, Iterable, List
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def build_web_runtime(
|
|
@@ -21,6 +22,7 @@ def build_web_runtime(
|
|
|
21
22
|
cors_extra_origins: Iterable[str],
|
|
22
23
|
cors_allow_network: bool,
|
|
23
24
|
static_dir: Path,
|
|
25
|
+
csrf_trusted_origins: Iterable[str] = (),
|
|
24
26
|
) -> Dict[str, Any]:
|
|
25
27
|
"""Create the FastAPI shell and mount static assets in legacy order."""
|
|
26
28
|
|
|
@@ -28,6 +30,9 @@ def build_web_runtime(
|
|
|
28
30
|
from fastapi.middleware.cors import CORSMiddleware
|
|
29
31
|
from fastapi.staticfiles import StaticFiles
|
|
30
32
|
|
|
33
|
+
from latticeai.core.csrf import CSRFOriginGuardMiddleware, CSRFOriginPolicy
|
|
34
|
+
from latticeai.core.security import host_is_loopback
|
|
35
|
+
|
|
31
36
|
app = FastAPI(
|
|
32
37
|
title=f"Lattice AI Server ({app_mode})",
|
|
33
38
|
version=app_version,
|
|
@@ -45,6 +50,27 @@ def build_web_runtime(
|
|
|
45
50
|
f"https://{default_host}:{default_port}",
|
|
46
51
|
]
|
|
47
52
|
|
|
53
|
+
# An origin that CORS already lets send *credentialed* cross-origin
|
|
54
|
+
# requests is, by that decision, trusted with the session cookie; listing
|
|
55
|
+
# it again here would be a second place to forget. The explicit
|
|
56
|
+
# LATTICEAI_CSRF_TRUSTED_ORIGINS entries are for the reverse-proxy case,
|
|
57
|
+
# where the public hostname is not the bind address.
|
|
58
|
+
csrf_allowed_origins: List[str] = [
|
|
59
|
+
*cors_allowed_origins,
|
|
60
|
+
*csrf_trusted_origins,
|
|
61
|
+
]
|
|
62
|
+
csrf_policy = CSRFOriginPolicy(
|
|
63
|
+
trusted_origins=csrf_allowed_origins,
|
|
64
|
+
server_host=default_host,
|
|
65
|
+
server_port=default_port,
|
|
66
|
+
bind_is_loopback=host_is_loopback(default_host),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Registration order is stack order in reverse: the LAST middleware added
|
|
70
|
+
# is the OUTERMOST. CORS must stay outside the guard so a rejected request
|
|
71
|
+
# still comes back with the CORS headers the browser needs to surface the
|
|
72
|
+
# 403 instead of an opaque network error.
|
|
73
|
+
app.add_middleware(CSRFOriginGuardMiddleware, policy=csrf_policy)
|
|
48
74
|
app.add_middleware(
|
|
49
75
|
CORSMiddleware,
|
|
50
76
|
allow_origins=cors_allowed_origins,
|
|
@@ -62,4 +88,5 @@ def build_web_runtime(
|
|
|
62
88
|
return {
|
|
63
89
|
"app": app,
|
|
64
90
|
"CORS_ALLOWED_ORIGINS": cors_allowed_origins,
|
|
91
|
+
"CSRF_ALLOWED_ORIGINS": csrf_allowed_origins,
|
|
65
92
|
}
|
|
@@ -267,7 +267,7 @@ class SearchService:
|
|
|
267
267
|
"metadata": match.get("metadata") or {},
|
|
268
268
|
"updated_at": match.get("updated_at"),
|
|
269
269
|
})
|
|
270
|
-
|
|
270
|
+
result = {
|
|
271
271
|
"query": query,
|
|
272
272
|
"mode": "vector",
|
|
273
273
|
"embedding_model": payload.get("embedding_model"),
|
|
@@ -278,6 +278,14 @@ class SearchService:
|
|
|
278
278
|
include_legacy_global=include_legacy_global,
|
|
279
279
|
),
|
|
280
280
|
}
|
|
281
|
+
# Honest recall passthrough: the graph layer scores a capped slice of a
|
|
282
|
+
# large vector index and says so. Dropping it here would turn "partial
|
|
283
|
+
# recall" into "these are all the matches" at the API boundary. It also
|
|
284
|
+
# rides into hybrid_search()["channels"]["vector"] for free.
|
|
285
|
+
recall = payload.get("recall")
|
|
286
|
+
if isinstance(recall, dict):
|
|
287
|
+
result["recall"] = recall
|
|
288
|
+
return result
|
|
281
289
|
|
|
282
290
|
def graph_search(
|
|
283
291
|
self,
|
|
@@ -34,6 +34,7 @@ from latticeai.tools import (
|
|
|
34
34
|
AGENT_ROOT,
|
|
35
35
|
DEFAULT_TOOL_REGISTRY,
|
|
36
36
|
ToolError,
|
|
37
|
+
document_output_target,
|
|
37
38
|
ensure_agent_root,
|
|
38
39
|
)
|
|
39
40
|
|
|
@@ -163,16 +164,20 @@ class ToolDispatchService:
|
|
|
163
164
|
detail=f"'{tool_name}' 툴에는 '{capability}' capability가 필요합니다.",
|
|
164
165
|
)
|
|
165
166
|
|
|
166
|
-
def _governed_path_exists(self, path: str) -> bool:
|
|
167
|
+
def _governed_path_exists(self, tool_name: str, path: str) -> bool:
|
|
167
168
|
"""Best-effort existence check for governance classification.
|
|
168
169
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
The document creators rewrite ``filename`` into their own output
|
|
171
|
+
directory, so the raw argument is resolved through
|
|
172
|
+
:func:`document_output_target` first — checking the argument verbatim
|
|
173
|
+
inspects a path nothing ever writes. Workspace-relative paths resolve
|
|
174
|
+
under ``AGENT_ROOT``; absolute paths (home-sandbox writes) are honored
|
|
175
|
+
as-is. Never raises — an unresolvable path is treated as non-existent
|
|
176
|
+
(additive), which the fail-closed guard only escalates when the target
|
|
177
|
+
actually exists.
|
|
173
178
|
"""
|
|
174
179
|
try:
|
|
175
|
-
candidate = Path(path)
|
|
180
|
+
candidate = Path(document_output_target(tool_name, path) or path)
|
|
176
181
|
if not candidate.is_absolute():
|
|
177
182
|
candidate = Path(AGENT_ROOT) / candidate
|
|
178
183
|
return candidate.exists()
|
|
@@ -244,7 +249,8 @@ class ToolDispatchService:
|
|
|
244
249
|
# overwrite has no safe apply path in any mode, so it stays blocked.
|
|
245
250
|
verdict = classify_tool_call(
|
|
246
251
|
tool_name, args or {},
|
|
247
|
-
policy=dict(policy),
|
|
252
|
+
policy=dict(policy),
|
|
253
|
+
path_exists=lambda candidate: self._governed_path_exists(tool_name, candidate),
|
|
248
254
|
)
|
|
249
255
|
if verdict.get("fail_closed"):
|
|
250
256
|
raise HTTPException(
|
|
@@ -269,6 +269,7 @@ __all__ = [
|
|
|
269
269
|
"search_files", "inspect_html", "preview_url", "create_web_project",
|
|
270
270
|
"todo_read", "todo_write",
|
|
271
271
|
"create_docx", "create_xlsx", "create_pptx", "create_pdf", "read_document",
|
|
272
|
+
"document_output_target",
|
|
272
273
|
"local_list", "local_read", "local_write", "desktop_bridge_status",
|
|
273
274
|
"knowledge_save", "knowledge_search", "knowledge_tree", "knowledge_scope_root",
|
|
274
275
|
"obsidian_save", "obsidian_search", "obsidian_tree",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Any, Dict, List
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
7
7
|
|
|
8
8
|
from latticeai.core.quiet import quiet
|
|
9
9
|
from latticeai.tools import (
|
|
@@ -28,6 +28,45 @@ def _safe_filename(name: str, suffix: str) -> str:
|
|
|
28
28
|
return safe or f"artifact{suffix}"
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
#: tool name → (output directory, enforced suffix). The creators below are the
|
|
32
|
+
#: only writers, so this table is the single source of truth for "where does
|
|
33
|
+
#: this document actually land?".
|
|
34
|
+
_DOCUMENT_TOOL_TARGETS: Dict[str, Tuple[str, str]] = {
|
|
35
|
+
"create_docx": (DOCUMENT_OUTPUT_DIR, ".docx"),
|
|
36
|
+
"create_xlsx": (SPREADSHEET_OUTPUT_DIR, ".xlsx"),
|
|
37
|
+
"create_pptx": (PRESENTATION_OUTPUT_DIR, ".pptx"),
|
|
38
|
+
"create_pdf": (PDF_OUTPUT_DIR, ".pdf"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def document_output_target(tool_name: str, filename: str) -> Optional[str]:
|
|
43
|
+
"""Workspace-relative path ``tool_name`` will write ``filename`` to.
|
|
44
|
+
|
|
45
|
+
Governance needs this: the creators sanitize the caller's ``filename``
|
|
46
|
+
into their own output directory, so an "does the target already exist?"
|
|
47
|
+
check against the raw argument looks at a path nothing ever writes — and
|
|
48
|
+
the fail-closed overwrite guard would silently never fire.
|
|
49
|
+
|
|
50
|
+
Returns ``None`` for tools that write wherever the caller points them, so
|
|
51
|
+
callers fall back to the raw argument.
|
|
52
|
+
"""
|
|
53
|
+
entry = _DOCUMENT_TOOL_TARGETS.get(tool_name)
|
|
54
|
+
if entry is None:
|
|
55
|
+
return None
|
|
56
|
+
output_dir, suffix = entry
|
|
57
|
+
return f"{output_dir}/{_safe_filename(filename, suffix)}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _document_target(tool_name: str, filename: str) -> Path:
|
|
61
|
+
"""Resolved, workspace-confined target for a document creator."""
|
|
62
|
+
relative = document_output_target(tool_name, filename)
|
|
63
|
+
if relative is None: # pragma: no cover — every creator is in the table
|
|
64
|
+
raise ToolError(f"'{tool_name}' has no document output target.")
|
|
65
|
+
target = _resolve_path(relative)
|
|
66
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
return target
|
|
68
|
+
|
|
69
|
+
|
|
31
70
|
def _body_to_str(body) -> str:
|
|
32
71
|
if isinstance(body, list):
|
|
33
72
|
return "\n\n".join(str(item) for item in body)
|
|
@@ -40,9 +79,7 @@ def create_docx(title: str, body, filename: str = "document.docx") -> Dict[str,
|
|
|
40
79
|
except Exception as exc:
|
|
41
80
|
raise ToolError("python-docx is not installed. Run `pip install -r requirements.txt`.") from exc
|
|
42
81
|
|
|
43
|
-
|
|
44
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
-
target = output_dir / _safe_filename(filename, ".docx")
|
|
82
|
+
target = _document_target("create_docx", filename)
|
|
46
83
|
|
|
47
84
|
document = Document()
|
|
48
85
|
if title:
|
|
@@ -64,9 +101,7 @@ def create_xlsx(rows: List[List[Any]], filename: str = "spreadsheet.xlsx", sheet
|
|
|
64
101
|
if not isinstance(rows, list) or not all(isinstance(row, list) for row in rows):
|
|
65
102
|
raise ToolError("Rows must be a list of lists.")
|
|
66
103
|
|
|
67
|
-
|
|
68
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
-
target = output_dir / _safe_filename(filename, ".xlsx")
|
|
104
|
+
target = _document_target("create_xlsx", filename)
|
|
70
105
|
|
|
71
106
|
workbook = Workbook()
|
|
72
107
|
sheet = workbook.active
|
|
@@ -83,9 +118,7 @@ def create_pptx(title: str, slides: List[Dict[str, Any]], filename: str = "prese
|
|
|
83
118
|
except Exception as exc:
|
|
84
119
|
raise ToolError("python-pptx is not installed. Run `pip install -r requirements.txt`.") from exc
|
|
85
120
|
|
|
86
|
-
|
|
87
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
-
target = output_dir / _safe_filename(filename, ".pptx")
|
|
121
|
+
target = _document_target("create_pptx", filename)
|
|
89
122
|
|
|
90
123
|
presentation = Presentation()
|
|
91
124
|
first_layout = presentation.slide_layouts[0]
|
|
@@ -124,9 +157,7 @@ def create_pdf(title: str, body, filename: str = "document.pdf") -> Dict[str, An
|
|
|
124
157
|
except Exception as exc:
|
|
125
158
|
raise ToolError("reportlab is not installed. Run `pip install reportlab`.") from exc
|
|
126
159
|
|
|
127
|
-
|
|
128
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
-
target = output_dir / _safe_filename(filename, ".pdf")
|
|
160
|
+
target = _document_target("create_pdf", filename)
|
|
130
161
|
|
|
131
162
|
# CJK 폰트 등록
|
|
132
163
|
font_name = "Helvetica"
|
package/package.json
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Post-processes the `vite build` output in place: normalises trailing
|
|
3
|
+
// whitespace in emitted css/js, rewrites the raw vite manifest into the
|
|
4
|
+
// app-facing asset-manifest.json, and stamps the service-worker cache version.
|
|
5
|
+
//
|
|
6
|
+
// `--out-dir <dir>` points the first two steps at a directory other than
|
|
7
|
+
// static/app so scripts/check_frontend_build_freshness.mjs can reproduce the
|
|
8
|
+
// exact shipped bytes into a scratch tree. In that mode the static/sw.js
|
|
9
|
+
// rewrite is skipped: it is a repo-level side effect, not part of the app dir,
|
|
10
|
+
// and a freshness check must never mutate the working tree.
|
|
2
11
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
const argv = process.argv.slice(2);
|
|
15
|
+
const outDirFlag = argv.indexOf("--out-dir");
|
|
16
|
+
const outDirArg = outDirFlag === -1 ? null : argv[outDirFlag + 1];
|
|
17
|
+
if (outDirFlag !== -1 && !outDirArg) {
|
|
18
|
+
console.error("--out-dir requires a directory argument");
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
4
21
|
|
|
5
22
|
const repo = join(import.meta.dirname, "..");
|
|
6
|
-
const appDir = join(repo, "static", "app");
|
|
23
|
+
const appDir = outDirArg ? resolve(outDirArg) : join(repo, "static", "app");
|
|
7
24
|
const nestedViteManifest = join(appDir, ".vite", "asset-manifest.json");
|
|
8
25
|
const publicManifest = join(appDir, "asset-manifest.json");
|
|
9
26
|
const serviceWorker = join(repo, "static", "sw.js");
|
|
@@ -46,7 +63,7 @@ const manifest = {
|
|
|
46
63
|
};
|
|
47
64
|
|
|
48
65
|
writeFileSync(publicManifest, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
49
|
-
if (existsSync(serviceWorker)) {
|
|
66
|
+
if (!outDirArg && existsSync(serviceWorker)) {
|
|
50
67
|
const cacheVersion = pkg.version.replace(/\D/g, "");
|
|
51
68
|
const source = readFileSync(serviceWorker, "utf8");
|
|
52
69
|
const normalized = source.replace(
|
|
@@ -55,4 +72,4 @@ if (existsSync(serviceWorker)) {
|
|
|
55
72
|
);
|
|
56
73
|
if (normalized !== source) writeFileSync(serviceWorker, normalized, "utf8");
|
|
57
74
|
}
|
|
58
|
-
console.log(`wrote
|
|
75
|
+
console.log(`wrote ${publicManifest} with ${Object.keys(assets).length} assets`);
|
|
@@ -128,12 +128,20 @@ async function main() {
|
|
|
128
128
|
await page.waitForTimeout(250);
|
|
129
129
|
await shot(page, "10-admin-console.png");
|
|
130
130
|
|
|
131
|
-
// The three named steps a file walks on its way into memory.
|
|
132
|
-
//
|
|
133
|
-
//
|
|
131
|
+
// The three named steps a file walks on its way into memory. 10.6.0 moved
|
|
132
|
+
// this out of a tab of its own and into Capture's second row, where it is now
|
|
133
|
+
// always on screen — so `#/pipeline` renders the same page as `#/capture`,
|
|
134
|
+
// and a full-page shot here came out byte-identical to 06. The README prints
|
|
135
|
+
// the two as different tiles, so frame the card this tile is actually about.
|
|
134
136
|
await page.goto(`${baseURL}/app#/pipeline`, { waitUntil: "networkidle" });
|
|
135
|
-
|
|
136
|
-
await
|
|
137
|
+
const journeyList = page.getByRole("list", { name: "자료가 기억이 되는 3단계" });
|
|
138
|
+
await journeyList.waitFor();
|
|
139
|
+
const journeyCard = page
|
|
140
|
+
.locator(".capture-secondary-column > *")
|
|
141
|
+
.filter({ has: journeyList })
|
|
142
|
+
.first();
|
|
143
|
+
await page.waitForTimeout(120);
|
|
144
|
+
await journeyCard.screenshot({ path: path.join(screenshots, "11-knowledge-journey.png") });
|
|
137
145
|
|
|
138
146
|
await page.goto(`${baseURL}/app#/review`, { waitUntil: "networkidle" });
|
|
139
147
|
await page.getByText(/Review Center|리뷰 센터|검토함/).first().waitFor();
|
|
@@ -6,7 +6,7 @@ const root = process.cwd();
|
|
|
6
6
|
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
7
7
|
const version = pkg.version;
|
|
8
8
|
const releaseDir = `output/release/v${version}`;
|
|
9
|
-
const releaseTheme = "
|
|
9
|
+
const releaseTheme = "Ask First";
|
|
10
10
|
const title = `${version} — ${releaseTheme}`;
|
|
11
11
|
const escapedVersion = version.replaceAll(".", "\\.");
|
|
12
12
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Frontend build freshness gate.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists
|
|
6
|
+
* ---------------
|
|
7
|
+
* The Playwright visual suite (`npm run test:visual`) drives the *committed*
|
|
8
|
+
* build output in `static/app/`. Nothing else proves that directory was built
|
|
9
|
+
* from the current `frontend/src/`. Change a component, forget
|
|
10
|
+
* `npm run build:assets`, and CI goes green against a UI that no longer exists.
|
|
11
|
+
*
|
|
12
|
+
* What it does
|
|
13
|
+
* ------------
|
|
14
|
+
* Reproduces `npm run build:assets` into a scratch directory and compares the
|
|
15
|
+
* result byte-for-byte with the committed `static/app/`. It runs the same two
|
|
16
|
+
* steps as the real build, in the same order:
|
|
17
|
+
* 1. `vite build --outDir <scratch> --emptyOutDir`
|
|
18
|
+
* 2. `scripts/build_frontend_assets.mjs --out-dir <scratch>`
|
|
19
|
+
* Step 2 matters: it strips trailing whitespace from the emitted css/js *after*
|
|
20
|
+
* vite has already computed the content hash in the filename, so the shipped
|
|
21
|
+
* bytes are not the raw rollup output. Comparing against raw vite output alone
|
|
22
|
+
* produces a guaranteed false failure on at least one chunk.
|
|
23
|
+
*
|
|
24
|
+
* It additionally checks that `static/sw.js` carries the cache constant that
|
|
25
|
+
* `build:assets` stamps from package.json, because that write is the one part
|
|
26
|
+
* of the build that lands outside `static/app/`.
|
|
27
|
+
*
|
|
28
|
+
* Determinism (measured, not assumed)
|
|
29
|
+
* -----------------------------------
|
|
30
|
+
* On an unchanged tree two consecutive `vite build` runs are byte-identical,
|
|
31
|
+
* including the content-hashed filenames, and the output does not depend on
|
|
32
|
+
* where `--outDir` points (verified with outDirs inside and outside the repo).
|
|
33
|
+
* So byte-exact comparison is the strictest gate that is actually stable, and
|
|
34
|
+
* that is what this script does.
|
|
35
|
+
*
|
|
36
|
+
* What it does NOT catch
|
|
37
|
+
* ----------------------
|
|
38
|
+
* * Toolchain drift. It compares a build made *now, here* against bytes
|
|
39
|
+
* committed *then, there*. A different vite/rolldown/tailwind version, or a
|
|
40
|
+
* platform whose native bindings emit different bytes, would fail this gate
|
|
41
|
+
* even though the source and the build output agree. `npm ci` against the
|
|
42
|
+
* committed package-lock.json is what keeps that from happening; if this
|
|
43
|
+
* gate ever fails with an identical file *set* but differing bytes, suspect
|
|
44
|
+
* the toolchain before suspecting the author.
|
|
45
|
+
* * Whether the built UI is *correct*. That is the visual suite's job. This
|
|
46
|
+
* only proves the visual suite is looking at the current source.
|
|
47
|
+
* * Anything outside `static/app/` and the `static/sw.js` cache constant.
|
|
48
|
+
*
|
|
49
|
+
* Exit codes: 0 fresh, 1 stale, 2 could not run the comparison at all.
|
|
50
|
+
*/
|
|
51
|
+
import { spawnSync } from "node:child_process";
|
|
52
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
53
|
+
import { tmpdir } from "node:os";
|
|
54
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
55
|
+
import { fileURLToPath } from "node:url";
|
|
56
|
+
|
|
57
|
+
const repo = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
58
|
+
const committedDir = join(repo, "static", "app");
|
|
59
|
+
const viteCli = join(repo, "node_modules", "vite", "bin", "vite.js");
|
|
60
|
+
const postBuild = join(repo, "scripts", "build_frontend_assets.mjs");
|
|
61
|
+
|
|
62
|
+
// vite writes its raw manifest to a `.vite/` subdirectory under some configs.
|
|
63
|
+
// build_frontend_assets.mjs reads it from either location and the repo does not
|
|
64
|
+
// commit it, so excluding it from both sides avoids a false "extra file".
|
|
65
|
+
const IGNORED_PREFIXES = [".vite/"];
|
|
66
|
+
|
|
67
|
+
function fail(message, code = 1) {
|
|
68
|
+
console.error(message);
|
|
69
|
+
process.exit(code);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function run(label, file, args) {
|
|
73
|
+
const result = spawnSync(process.execPath, [file, ...args], {
|
|
74
|
+
cwd: repo,
|
|
75
|
+
env: process.env,
|
|
76
|
+
stdio: "inherit",
|
|
77
|
+
});
|
|
78
|
+
if (result.error) fail(`${label} could not start: ${result.error.message}`, 2);
|
|
79
|
+
if (result.status !== 0) fail(`${label} failed with status ${result.status ?? "unknown"}`, 2);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function walk(root, base = root, out = new Map()) {
|
|
83
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
84
|
+
const full = join(root, entry.name);
|
|
85
|
+
const rel = relative(base, full).split("\\").join("/");
|
|
86
|
+
if (IGNORED_PREFIXES.some((prefix) => rel === prefix.slice(0, -1) || rel.startsWith(prefix))) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (entry.isDirectory()) walk(full, base, out);
|
|
90
|
+
else out.set(rel, full);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkServiceWorker() {
|
|
96
|
+
const swPath = join(repo, "static", "sw.js");
|
|
97
|
+
if (!existsSync(swPath)) return null;
|
|
98
|
+
const version = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version;
|
|
99
|
+
const expected = `const CACHE = "lattice-v${version.replace(/\D/g, "")}";`;
|
|
100
|
+
return readFileSync(swPath, "utf8").includes(expected)
|
|
101
|
+
? null
|
|
102
|
+
: `static/sw.js is missing ${expected} (build:assets stamps it from package.json).`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!existsSync(committedDir)) {
|
|
106
|
+
fail(`Committed build output is missing: ${committedDir}\nRun \`npm run build:assets\` and commit static/app/.`);
|
|
107
|
+
}
|
|
108
|
+
if (!existsSync(viteCli)) {
|
|
109
|
+
fail(`vite is not installed at ${viteCli}. Run \`npm ci\` first.`, 2);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const scratchRoot = mkdtempSync(join(tmpdir(), "ltcai-frontend-freshness-"));
|
|
113
|
+
const freshDir = join(scratchRoot, "app");
|
|
114
|
+
let exitCode = 0;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
run("vite build", viteCli, ["build", "--outDir", freshDir, "--emptyOutDir"]);
|
|
118
|
+
run("build_frontend_assets.mjs", postBuild, ["--out-dir", freshDir]);
|
|
119
|
+
|
|
120
|
+
const fresh = walk(freshDir);
|
|
121
|
+
const committed = walk(committedDir);
|
|
122
|
+
|
|
123
|
+
const missing = [...committed.keys()].filter((rel) => !fresh.has(rel)).sort();
|
|
124
|
+
const extra = [...fresh.keys()].filter((rel) => !committed.has(rel)).sort();
|
|
125
|
+
const changed = [...fresh.keys()]
|
|
126
|
+
.filter((rel) => committed.has(rel))
|
|
127
|
+
.filter((rel) => !readFileSync(fresh.get(rel)).equals(readFileSync(committed.get(rel))))
|
|
128
|
+
.sort();
|
|
129
|
+
|
|
130
|
+
const swProblem = checkServiceWorker();
|
|
131
|
+
|
|
132
|
+
if (!missing.length && !extra.length && !changed.length && !swProblem) {
|
|
133
|
+
console.log(
|
|
134
|
+
`static/app/ is in sync with frontend/src/ (${committed.size} files rebuilt and byte-identical).`,
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
exitCode = 1;
|
|
138
|
+
console.error("\nstatic/app/ is STALE — it was not built from the current frontend/src/.\n");
|
|
139
|
+
if (extra.length) {
|
|
140
|
+
console.error(` ${extra.length} file(s) the current source produces but static/app/ does not have:`);
|
|
141
|
+
for (const rel of extra.slice(0, 20)) console.error(` + ${rel}`);
|
|
142
|
+
if (extra.length > 20) console.error(` ... and ${extra.length - 20} more`);
|
|
143
|
+
}
|
|
144
|
+
if (missing.length) {
|
|
145
|
+
console.error(` ${missing.length} committed file(s) the current source no longer produces:`);
|
|
146
|
+
for (const rel of missing.slice(0, 20)) console.error(` - ${rel}`);
|
|
147
|
+
if (missing.length > 20) console.error(` ... and ${missing.length - 20} more`);
|
|
148
|
+
}
|
|
149
|
+
if (changed.length) {
|
|
150
|
+
console.error(` ${changed.length} file(s) with the same name but different bytes:`);
|
|
151
|
+
for (const rel of changed.slice(0, 20)) {
|
|
152
|
+
const a = statSync(committed.get(rel)).size;
|
|
153
|
+
const b = statSync(fresh.get(rel)).size;
|
|
154
|
+
console.error(` ~ ${rel} (committed ${a} bytes, rebuilt ${b} bytes)`);
|
|
155
|
+
}
|
|
156
|
+
if (changed.length > 20) console.error(` ... and ${changed.length - 20} more`);
|
|
157
|
+
console.error(
|
|
158
|
+
" Same names with different bytes and no added/removed files can also mean\n" +
|
|
159
|
+
" toolchain drift rather than stale output — check that `npm ci` installed the\n" +
|
|
160
|
+
" versions in package-lock.json before assuming the source changed.",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (swProblem) console.error(` ${swProblem}`);
|
|
164
|
+
console.error("\nFix: run `npm run build:assets` and commit static/app/ (and static/sw.js).\n");
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
rmSync(scratchRoot, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
process.exit(exitCode);
|
package/src-tauri/Cargo.lock
CHANGED
package/src-tauri/Cargo.toml
CHANGED