ltcai 10.6.2 → 10.7.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 +44 -59
- package/docs/CHANGELOG.md +56 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/LAYOUT_REBUILD_SPEC.md +810 -0
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +29 -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/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +72 -0
- package/latticeai/api/automation_intelligence.py +112 -0
- package/latticeai/api/knowledge_graph.py +133 -0
- package/latticeai/api/static_routes.py +35 -1
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +3 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_runs.py +111 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +5 -3
- package/scripts/capture_release_evidence.mjs +335 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_release_evidence_bound.mjs +116 -0
- package/scripts/check_screenshot_pixel_delta.py +435 -0
- package/scripts/measure_brain_home_fill.mjs +141 -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 +37 -39
- package/static/app/assets/{Act-aNud-lKL.js → Act-DIbkoJqs.js} +1 -1
- package/static/app/assets/AdminConsole-Cbi6Jre6.js +1 -0
- package/static/app/assets/{Brain-CMFh5q6k.js → Brain-BPfuXZ71.js} +2 -2
- package/static/app/assets/BrainHome-87XaAS4V.js +2 -0
- package/static/app/assets/{BrainSignals-RVMlmTGC.js → BrainSignals-ExpcdiUi.js} +1 -1
- package/static/app/assets/Capture-Bxe4WiVX.js +1 -0
- package/static/app/assets/{CommandPalette-bW5WxWWZ.js → CommandPalette-B1PV5Yd4.js} +1 -1
- package/static/app/assets/Library-BRlJJmwZ.js +1 -0
- package/static/app/assets/{LivingBrain-FlHFfu9i.js → LivingBrain-D61qG3WY.js} +1 -1
- package/static/app/assets/ProductFlow-BxjxI8Aa.js +1 -0
- package/static/app/assets/{ReviewCard-BHj86h2Z.js → ReviewCard-BoiWv0Y1.js} +2 -2
- package/static/app/assets/System-Ci0HEtTJ.js +1 -0
- package/static/app/assets/arrow-left-DArdBUxd.js +1 -0
- package/static/app/assets/{bot-D1gX4xks.js → bot-BaPt4PPn.js} +1 -1
- package/static/app/assets/brain-BSkWaj3i.js +1 -0
- package/static/app/assets/{button-DPFZ9lGw.js → button-CKwtO1O0.js} +1 -1
- package/static/app/assets/{circle-pause-B2P72YOO.js → circle-pause-CiUc5RRk.js} +1 -1
- package/static/app/assets/{circle-play-BiC8z2vg.js → circle-play-BaTDxzR9.js} +1 -1
- package/static/app/assets/{cpu-Cx-wRR_V.js → cpu-Dm1gHuP1.js} +1 -1
- package/static/app/assets/{download-CKxlzxsK.js → download-C32oGU-C.js} +1 -1
- package/static/app/assets/{folder-open-Bbjju0tt.js → folder-open-Ch2jLVdD.js} +1 -1
- package/static/app/assets/{hard-drive-Bon1VBvq.js → hard-drive-BBjA5st8.js} +1 -1
- package/static/app/assets/index-BV7Yf6Eq.css +2 -0
- package/static/app/assets/{index-mLP0-YNO.js → index-J6h01X78.js} +4 -4
- package/static/app/assets/{input-DrMc0Xns.js → input-y6ABdKWY.js} +1 -1
- package/static/app/assets/{permissionCopy-BGUsI7vw.js → permissionCopy-BEtc0Ihd.js} +1 -1
- package/static/app/assets/{primitives-CEMTjBz1.js → primitives-GSFXF2Pd.js} +1 -1
- package/static/app/assets/search-CaIZKyo_.js +1 -0
- package/static/app/assets/{share-2-D7THHq5K.js → share-2-BgGq2A63.js} +1 -1
- package/static/app/assets/{shield-alert-BoU4_8r9.js → shield-alert-BYA491cj.js} +1 -1
- package/static/app/assets/{textarea-rU1Lb7ia.js → textarea-BAcim3fs.js} +1 -1
- package/static/app/assets/{useFocusTrap-BgvK4Nkx.js → useFocusTrap-qLg24jiv.js} +1 -1
- package/static/app/assets/{useQuery-CT2ChyuU.js → useQuery-xkAu9eDX.js} +1 -1
- package/static/app/assets/utils-CogdofAA.js +4 -0
- package/static/app/assets/workspace-BMvfx9_R.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/AdminConsole-QiCTH68K.js +0 -1
- package/static/app/assets/BrainHome-B4Ar3atu.js +0 -2
- package/static/app/assets/Capture-DEBo-0vZ.js +0 -1
- package/static/app/assets/Library-5gFexm83.js +0 -1
- package/static/app/assets/ProductFlow-CFUNDOHu.js +0 -1
- package/static/app/assets/System-CeNHoZRu.js +0 -1
- package/static/app/assets/activity-7_ZmZqN0.js +0 -1
- package/static/app/assets/arrow-left-Dv2Tiwhe.js +0 -1
- package/static/app/assets/brain-Dn_bDfl4.js +0 -1
- package/static/app/assets/index-DYUs0cWy.css +0 -2
- package/static/app/assets/search-HsIji1wY.js +0 -1
- package/static/app/assets/users-DlbfHBQV.js +0 -1
- package/static/app/assets/utils-fEGWreKB.js +0 -7
- package/static/app/assets/workspace-ClDBz_0f.js +0 -1
|
@@ -434,6 +434,117 @@ class WorkspaceRuns:
|
|
|
434
434
|
runs = [run for run in runs if run.get("workflow_id") == workflow_id]
|
|
435
435
|
return {"runs": list(reversed(runs[-max(1, min(limit, 300)):]))}
|
|
436
436
|
|
|
437
|
+
def list_combined_runs(
|
|
438
|
+
self,
|
|
439
|
+
*,
|
|
440
|
+
limit: int = 20,
|
|
441
|
+
workspace_id: Optional[str] = None,
|
|
442
|
+
) -> Dict[str, Any]:
|
|
443
|
+
"""Merge agent + workflow runs into one ``started_at``-descending timeline.
|
|
444
|
+
|
|
445
|
+
Response shape is the product contract for Act screen 09 and the
|
|
446
|
+
layout-rebuild activity feed: each row carries ``source``, a human
|
|
447
|
+
``title``, stop/resume affordances, and normalized timestamps.
|
|
448
|
+
"""
|
|
449
|
+
capped = max(1, min(int(limit or 20), 100))
|
|
450
|
+
state = self.load_state()
|
|
451
|
+
agent_runs = self._scoped(_listify(state.get("agent_runs")), workspace_id)
|
|
452
|
+
workflow_runs = self._scoped(_listify(state.get("workflow_runs")), workspace_id)
|
|
453
|
+
combined: List[Dict[str, Any]] = []
|
|
454
|
+
for run in agent_runs:
|
|
455
|
+
combined.append(self.activity_run_row(run, source="agent"))
|
|
456
|
+
for run in workflow_runs:
|
|
457
|
+
combined.append(self.activity_run_row(run, source="workflow"))
|
|
458
|
+
combined.sort(key=lambda row: str(row.get("started_at") or ""), reverse=True)
|
|
459
|
+
total = len(combined)
|
|
460
|
+
return {
|
|
461
|
+
"runs": combined[:capped],
|
|
462
|
+
"total": total,
|
|
463
|
+
"truncated": total > capped,
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _first_string(*values: Any) -> str:
|
|
468
|
+
for value in values:
|
|
469
|
+
if isinstance(value, str) and value.strip():
|
|
470
|
+
return value.strip()
|
|
471
|
+
return ""
|
|
472
|
+
|
|
473
|
+
@classmethod
|
|
474
|
+
def activity_run_title(cls, run: Dict[str, Any]) -> str:
|
|
475
|
+
"""Public title resolver shared by list_combined_runs and router fallbacks."""
|
|
476
|
+
direct = cls._first_string(
|
|
477
|
+
run.get("workflow_name"),
|
|
478
|
+
run.get("name"),
|
|
479
|
+
run.get("goal"),
|
|
480
|
+
run.get("title"),
|
|
481
|
+
run.get("query"),
|
|
482
|
+
)
|
|
483
|
+
if direct:
|
|
484
|
+
return direct
|
|
485
|
+
raw_input = run.get("input")
|
|
486
|
+
if isinstance(raw_input, str) and raw_input.strip():
|
|
487
|
+
return raw_input.strip()
|
|
488
|
+
if isinstance(raw_input, dict):
|
|
489
|
+
nested = cls._first_string(
|
|
490
|
+
raw_input.get("goal"),
|
|
491
|
+
raw_input.get("name"),
|
|
492
|
+
raw_input.get("title"),
|
|
493
|
+
raw_input.get("query"),
|
|
494
|
+
raw_input.get("text"),
|
|
495
|
+
raw_input.get("prompt"),
|
|
496
|
+
)
|
|
497
|
+
if nested:
|
|
498
|
+
return nested
|
|
499
|
+
return ""
|
|
500
|
+
|
|
501
|
+
@classmethod
|
|
502
|
+
def activity_run_row(cls, run: Dict[str, Any], *, source: str) -> Dict[str, Any]:
|
|
503
|
+
"""Normalize an agent or workflow run into the Act activity-feed row shape.
|
|
504
|
+
|
|
505
|
+
Public so API routers (e.g. automation_intelligence fallback) can reuse
|
|
506
|
+
the same contract without reaching for a private method.
|
|
507
|
+
|
|
508
|
+
``source`` is the product discriminator for the 레시피/목표 badge.
|
|
509
|
+
Legacy agent runs may lack ``agent_id`` (and both ids may be None) —
|
|
510
|
+
those rows must still leave this method with ``source="agent"`` so the
|
|
511
|
+
Act feed does not collapse every goal row into a recipe badge.
|
|
512
|
+
"""
|
|
513
|
+
status = str(run.get("status") or "")
|
|
514
|
+
started_at = (
|
|
515
|
+
run.get("started_at")
|
|
516
|
+
or run.get("created_at")
|
|
517
|
+
or run.get("updated_at")
|
|
518
|
+
or ""
|
|
519
|
+
)
|
|
520
|
+
finished_at = (
|
|
521
|
+
run.get("finished_at")
|
|
522
|
+
or run.get("completed_at")
|
|
523
|
+
or run.get("resolved_at")
|
|
524
|
+
or run.get("interrupted_at")
|
|
525
|
+
)
|
|
526
|
+
run_id = run.get("id") or run.get("run_id")
|
|
527
|
+
normalized_source = str(source or "").strip().lower()
|
|
528
|
+
if normalized_source not in {"agent", "workflow"}:
|
|
529
|
+
# Infer only when the caller failed to pin a valid source; prefer
|
|
530
|
+
# workflow_id when present, otherwise agent (including bare legacy).
|
|
531
|
+
if run.get("workflow_id"):
|
|
532
|
+
normalized_source = "workflow"
|
|
533
|
+
else:
|
|
534
|
+
normalized_source = "agent"
|
|
535
|
+
return {
|
|
536
|
+
"id": run_id,
|
|
537
|
+
"source": normalized_source,
|
|
538
|
+
"title": cls.activity_run_title(run),
|
|
539
|
+
"status": status,
|
|
540
|
+
"started_at": started_at,
|
|
541
|
+
"finished_at": finished_at,
|
|
542
|
+
"can_stop": status in RUN_ACTIVE_STATUSES,
|
|
543
|
+
"can_resume": status in {"awaiting_approval", "paused", "waiting_approval"},
|
|
544
|
+
"workflow_id": run.get("workflow_id"),
|
|
545
|
+
"agent_id": run.get("agent_id"),
|
|
546
|
+
}
|
|
547
|
+
|
|
437
548
|
def mark_workflow_run_resolved(
|
|
438
549
|
self, run_id: str, *, resumed_run_id: str, approved: bool,
|
|
439
550
|
workspace_id: Optional[str] = None,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.7.0",
|
|
4
4
|
"description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"build:assets": "vite build && node scripts/build_frontend_assets.mjs",
|
|
25
25
|
"build:python": "node scripts/run_python.mjs -m build",
|
|
26
26
|
"check:python": "node scripts/run_python.mjs scripts/check_python.py",
|
|
27
|
-
"lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run check:i18n-namespaces && npm run check:bundle && npm run check:legacy-debt && npm run test:browser-extension && npm run test:vscode-extension",
|
|
27
|
+
"lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run check:i18n-namespaces && npm run check:bundle && npm run check:legacy-debt && npm run test:browser-extension && npm run test:vscode-extension && node scripts/check_release_evidence_bound.mjs",
|
|
28
|
+
"check:release-evidence-bound": "node scripts/check_release_evidence_bound.mjs",
|
|
28
29
|
"check:legacy-debt": "node scripts/check_legacy_debt.mjs",
|
|
29
30
|
"lint:python": "node scripts/run_python.mjs -m ruff check . && npm run typecheck:python",
|
|
30
31
|
"lint:frontend": "node scripts/lint_frontend.mjs",
|
|
@@ -48,7 +49,8 @@
|
|
|
48
49
|
"desktop:tauri:check": "cd src-tauri && cargo check",
|
|
49
50
|
"desktop:electron": "electron desktop/electron/main.cjs",
|
|
50
51
|
"package:vsix": "node scripts/build_vsix.mjs",
|
|
51
|
-
"
|
|
52
|
+
"check:screenshot-delta": "node scripts/run_python.mjs scripts/check_screenshot_pixel_delta.py",
|
|
53
|
+
"release:evidence": "node scripts/capture_release_evidence.mjs && node scripts/prune_release_evidence.mjs && npm run check:screenshot-delta",
|
|
52
54
|
"release:evidence:prune": "node scripts/prune_release_evidence.mjs",
|
|
53
55
|
"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",
|
|
54
56
|
"release:validate": "node scripts/run_python.mjs scripts/validate_release_artifacts.py $npm_package_version --require-vsix --require-tgz --require-dmg",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { chromium } from "playwright";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
3
4
|
import fs from "node:fs";
|
|
4
5
|
import path from "node:path";
|
|
@@ -7,10 +8,243 @@ const repoRoot = process.cwd();
|
|
|
7
8
|
const version = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
|
|
8
9
|
const port = Number(process.env.LTCAI_RELEASE_EVIDENCE_PORT || 4936);
|
|
9
10
|
const baseURL = `http://127.0.0.1:${port}`;
|
|
10
|
-
|
|
11
|
+
// Defaults to the published directory for this version, but can be redirected.
|
|
12
|
+
// This script starts by deleting `root` outright, so anything that wants a
|
|
13
|
+
// preview capture — a reviewer checking whether the UI actually moved before
|
|
14
|
+
// the version is bumped — would otherwise have to overwrite the screenshots of
|
|
15
|
+
// an already-released version to get one.
|
|
16
|
+
const root =
|
|
17
|
+
process.env.LTCAI_RELEASE_EVIDENCE_DIR ||
|
|
18
|
+
path.join(repoRoot, "output", "release", `v${version}`);
|
|
11
19
|
const screenshots = path.join(root, "screenshots");
|
|
12
20
|
const videos = path.join(root, "videos");
|
|
13
21
|
const gifs = path.join(root, "gifs");
|
|
22
|
+
const manifestPath = path.join(repoRoot, "static", "app", "asset-manifest.json");
|
|
23
|
+
|
|
24
|
+
function assetManifestFingerprint() {
|
|
25
|
+
if (!fs.existsSync(manifestPath)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const body = fs.readFileSync(manifestPath);
|
|
29
|
+
const stat = fs.statSync(manifestPath);
|
|
30
|
+
return {
|
|
31
|
+
sha256: createHash("sha256").update(body).digest("hex"),
|
|
32
|
+
mtime: stat.mtime.toISOString(),
|
|
33
|
+
bytes: body.length,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const mockServerPath = path.join(repoRoot, "tests", "visual", "mock_server.cjs");
|
|
38
|
+
|
|
39
|
+
function mockServerFingerprint() {
|
|
40
|
+
if (!fs.existsSync(mockServerPath)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const body = fs.readFileSync(mockServerPath);
|
|
44
|
+
const stat = fs.statSync(mockServerPath);
|
|
45
|
+
return {
|
|
46
|
+
sha256: createHash("sha256").update(body).digest("hex"),
|
|
47
|
+
mtime: stat.mtime.toISOString(),
|
|
48
|
+
bytes: body.length,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Refuse to overwrite published release screenshots for an already-tagged version.
|
|
54
|
+
*
|
|
55
|
+
* Failure mode this catches: package.json is still 10.6.3, tag v10.6.3 exists
|
|
56
|
+
* (the shipped release), and capture writes into output/release/v10.6.3/,
|
|
57
|
+
* silently replacing the public screenshots of a released version with WIP UI.
|
|
58
|
+
* Preview captures must use LTCAI_RELEASE_EVIDENCE_DIR (any path other than the
|
|
59
|
+
* default published dir). To land new evidence under output/release/, bump the
|
|
60
|
+
* package version first so the tag name no longer collides.
|
|
61
|
+
*
|
|
62
|
+
* Guard runs *before* wiping `root`, so a tagged release is never deleted by
|
|
63
|
+
* an unbumped recapture.
|
|
64
|
+
*/
|
|
65
|
+
function assertNotOverwritingTaggedRelease() {
|
|
66
|
+
const defaultRoot = path.join(repoRoot, "output", "release", `v${version}`);
|
|
67
|
+
const writingToPublished = path.resolve(root) === path.resolve(defaultRoot);
|
|
68
|
+
if (!writingToPublished) {
|
|
69
|
+
// Explicit LTCAI_RELEASE_EVIDENCE_DIR redirect (preview) — allowed even
|
|
70
|
+
// when the version tag already exists.
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let tagExists = false;
|
|
75
|
+
try {
|
|
76
|
+
execFileSync("git", ["rev-parse", "--verify", `refs/tags/v${version}`], {
|
|
77
|
+
cwd: repoRoot,
|
|
78
|
+
stdio: "pipe",
|
|
79
|
+
});
|
|
80
|
+
tagExists = true;
|
|
81
|
+
} catch {
|
|
82
|
+
tagExists = false;
|
|
83
|
+
}
|
|
84
|
+
if (!tagExists) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.error(
|
|
89
|
+
`release evidence: git tag v${version} already exists.\n` +
|
|
90
|
+
` Refusing to overwrite ${path.relative(repoRoot, defaultRoot)}/ — that would\n` +
|
|
91
|
+
` replace the published screenshots of an already-released version with WIP UI.\n` +
|
|
92
|
+
` Bump the package version first (e.g. node scripts/run_python.mjs scripts/bump_version.py),\n` +
|
|
93
|
+
` or set LTCAI_RELEASE_EVIDENCE_DIR to a non-published path for a preview capture.`,
|
|
94
|
+
);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Refuse to capture when the release lint gate is red.
|
|
100
|
+
*
|
|
101
|
+
* Failure mode this catches: lint is failing (e.g. ruff F841) but
|
|
102
|
+
* capture still runs and writes 12 screenshots + SCREENSHOT_INDEX.md
|
|
103
|
+
* bound to the asset manifest — evidence that looks green while the
|
|
104
|
+
* first CI stage would already have stopped. Capture is not proof that
|
|
105
|
+
* the tree is releasable; lint must pass first.
|
|
106
|
+
*
|
|
107
|
+
* Guard runs *before* wiping `root`, so a red lint never deletes the
|
|
108
|
+
* last good evidence directory.
|
|
109
|
+
*/
|
|
110
|
+
function assertLintClean() {
|
|
111
|
+
console.log("release evidence: running npm run lint before capture…");
|
|
112
|
+
try {
|
|
113
|
+
// Skip the evidence↔build binding gate during intentional recapture:
|
|
114
|
+
// this run is about to wipe and rebind SCREENSHOT_INDEX.md. Day-to-day
|
|
115
|
+
// `npm run lint` still hard-fails when screenshots are stale.
|
|
116
|
+
execFileSync("npm", ["run", "lint"], {
|
|
117
|
+
cwd: repoRoot,
|
|
118
|
+
stdio: "inherit",
|
|
119
|
+
env: { ...process.env, LTCAI_SKIP_RELEASE_EVIDENCE_BOUND: "1" },
|
|
120
|
+
});
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const code = err && typeof err === "object" && "status" in err ? err.status : 1;
|
|
123
|
+
console.error(
|
|
124
|
+
"release evidence: npm run lint failed — capture aborted.\n" +
|
|
125
|
+
" Fix lint (ruff / frontend / openapi / i18n gates) before release:evidence.\n" +
|
|
126
|
+
` exit status: ${code}`,
|
|
127
|
+
);
|
|
128
|
+
process.exit(typeof code === "number" && code !== 0 ? code : 1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Refuse to capture when static/app is older than frontend/src.
|
|
134
|
+
*
|
|
135
|
+
* Failure mode this catches: someone edits frontend/src, skips
|
|
136
|
+
* `npm run build:assets`, then runs release:evidence. Screenshots look
|
|
137
|
+
* "fresh" (new mtimes) but still show the previous build. That shipped
|
|
138
|
+
* stale evidence in 10.6.3 (asset mtime later than screenshot mtime by
|
|
139
|
+
* 42–50s). Warn-and-continue is useless — exit 1 so CI and humans stop.
|
|
140
|
+
*
|
|
141
|
+
* Guard runs *before* wiping `root`, so a stale build never deletes the
|
|
142
|
+
* last good evidence directory.
|
|
143
|
+
*/
|
|
144
|
+
function assertBuiltAssetsFresh() {
|
|
145
|
+
const srcRoot = path.join(repoRoot, "frontend", "src");
|
|
146
|
+
if (!fs.existsSync(manifestPath)) {
|
|
147
|
+
console.error(
|
|
148
|
+
"release evidence: static/app/asset-manifest.json 이 없습니다.\n" +
|
|
149
|
+
"실행 전에 npm run build:assets 를 돌려라.",
|
|
150
|
+
);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
if (!fs.existsSync(srcRoot)) {
|
|
154
|
+
console.error(`release evidence: frontend/src 가 없습니다: ${srcRoot}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let newestSrcMtime = 0;
|
|
159
|
+
let newestSrcPath = srcRoot;
|
|
160
|
+
const walk = (dir) => {
|
|
161
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
162
|
+
const full = path.join(dir, entry.name);
|
|
163
|
+
if (entry.isDirectory()) {
|
|
164
|
+
walk(full);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!entry.isFile()) continue;
|
|
168
|
+
const mtime = fs.statSync(full).mtimeMs;
|
|
169
|
+
if (mtime > newestSrcMtime) {
|
|
170
|
+
newestSrcMtime = mtime;
|
|
171
|
+
newestSrcPath = full;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
walk(srcRoot);
|
|
176
|
+
|
|
177
|
+
const manifestMtime = fs.statSync(manifestPath).mtimeMs;
|
|
178
|
+
if (newestSrcMtime > manifestMtime) {
|
|
179
|
+
const rel = path.relative(repoRoot, newestSrcPath);
|
|
180
|
+
console.error(
|
|
181
|
+
"release evidence: frontend/src 가 static/app 빌드보다 더 새롭습니다.\n" +
|
|
182
|
+
` 최신 소스: ${rel}\n` +
|
|
183
|
+
` 소스 mtime: ${new Date(newestSrcMtime).toISOString()}\n` +
|
|
184
|
+
` 빌드 mtime: ${new Date(manifestMtime).toISOString()} (static/app/asset-manifest.json)\n` +
|
|
185
|
+
"실행 전에 npm run build:assets 를 돌려라.",
|
|
186
|
+
);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Detect evidence that was captured against a *previous* asset build.
|
|
193
|
+
*
|
|
194
|
+
* Failure mode: capture runs (screenshots mtime T0), then someone rebuilds
|
|
195
|
+
* assets (manifest mtime T1 > T0). The pre-guard only compares src vs
|
|
196
|
+
* manifest, so it still passes — but screenshots no longer show the build
|
|
197
|
+
* that will ship. Recording the manifest sha256 in SCREENSHOT_INDEX.md at
|
|
198
|
+
* capture time, then refusing to trust a mismatched index, closes that hole.
|
|
199
|
+
*
|
|
200
|
+
* Day-to-day gate: `scripts/check_release_evidence_bound.mjs` (in npm run
|
|
201
|
+
* lint) hard-fails on sha mismatch. This pre-capture check is different —
|
|
202
|
+
* when someone deliberately runs release:evidence after build:assets, the
|
|
203
|
+
* existing index is *expected* to be stale; we continue, wipe, and rebind.
|
|
204
|
+
* Set LTCAI_RELEASE_EVIDENCE_REQUIRE_BOUND=1 to refuse wipe-and-rebind
|
|
205
|
+
* (useful in CI that should never recapture from a dirty tree).
|
|
206
|
+
*/
|
|
207
|
+
function assertExistingEvidenceStillBoundToBuild() {
|
|
208
|
+
const indexPath = path.join(root, "SCREENSHOT_INDEX.md");
|
|
209
|
+
if (!fs.existsSync(indexPath)) return;
|
|
210
|
+
const index = fs.readFileSync(indexPath, "utf8");
|
|
211
|
+
const match = index.match(/asset-manifest\.sha256:\s*`?([0-9a-f]{64})`?/i);
|
|
212
|
+
if (!match) {
|
|
213
|
+
// Older captures predate binding — this run will rebind after wipe.
|
|
214
|
+
console.warn(
|
|
215
|
+
"release evidence: existing SCREENSHOT_INDEX.md has no asset-manifest.sha256 binding.\n" +
|
|
216
|
+
" Recapture will record the current build fingerprint.",
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const recorded = match[1].toLowerCase();
|
|
221
|
+
const current = assetManifestFingerprint();
|
|
222
|
+
if (!current) return;
|
|
223
|
+
if (recorded === current.sha256) return;
|
|
224
|
+
|
|
225
|
+
const msg =
|
|
226
|
+
"release evidence: SCREENSHOT_INDEX.md is bound to a different asset build.\n" +
|
|
227
|
+
` recorded sha256: ${recorded}\n` +
|
|
228
|
+
` current sha256: ${current.sha256}\n` +
|
|
229
|
+
` current mtime: ${current.mtime}\n` +
|
|
230
|
+
"Evidence screenshots are stale relative to static/app.";
|
|
231
|
+
if (process.env.LTCAI_RELEASE_EVIDENCE_REQUIRE_BOUND === "1") {
|
|
232
|
+
console.error(
|
|
233
|
+
`${msg}\n LTCAI_RELEASE_EVIDENCE_REQUIRE_BOUND=1 — refusing wipe/rebind.\n` +
|
|
234
|
+
" Unset the flag and re-run release:evidence to recapture, or restore the matching build.",
|
|
235
|
+
);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
console.warn(
|
|
239
|
+
`${msg}\n Continuing: this run will wipe and rebind evidence to the current build.\n` +
|
|
240
|
+
" (Day-to-day lint still fails on stale evidence via check_release_evidence_bound.mjs.)",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
assertNotOverwritingTaggedRelease();
|
|
245
|
+
assertLintClean();
|
|
246
|
+
assertBuiltAssetsFresh();
|
|
247
|
+
assertExistingEvidenceStillBoundToBuild();
|
|
14
248
|
|
|
15
249
|
// A release capture must be reproducible and must never retain partial videos
|
|
16
250
|
// from an interrupted prior run.
|
|
@@ -42,6 +276,59 @@ async function shot(page, name) {
|
|
|
42
276
|
await page.screenshot({ path: path.join(screenshots, name), fullPage: true });
|
|
43
277
|
}
|
|
44
278
|
|
|
279
|
+
// The README's hero GIF is this session's screen recording, and 26 of its 79
|
|
280
|
+
// frames were the route-level Suspense fallback ("Brain 작업공간을 여는 중...",
|
|
281
|
+
// App.tsx PageLoader): every screen visited below is a separate lazy chunk, so a
|
|
282
|
+
// cold headless capture pays that chunk's parse cost on first visit and records
|
|
283
|
+
// an empty shell instead of the product. A third of the walkthrough published in
|
|
284
|
+
// the README was therefore a loading screen. Preloading the route chunks once,
|
|
285
|
+
// while the Brain home is already on screen, makes the recording show the
|
|
286
|
+
// screens a person came to see rather than the loader between them.
|
|
287
|
+
//
|
|
288
|
+
// The asset base is read from the module script the page already loaded instead
|
|
289
|
+
// of being hardcoded, so moving the static mount cannot quietly turn this into a
|
|
290
|
+
// no-op — and resolving zero chunks throws rather than shipping a blank GIF.
|
|
291
|
+
async function preloadRouteChunks(page) {
|
|
292
|
+
const chunks = [
|
|
293
|
+
...new Set(fs.readFileSync(manifestPath, "utf8").match(/assets\/[A-Za-z0-9_-]+\.js/g) ?? []),
|
|
294
|
+
];
|
|
295
|
+
if (!chunks.length) {
|
|
296
|
+
throw new Error(`release evidence: no route chunks found in ${manifestPath}`);
|
|
297
|
+
}
|
|
298
|
+
const loaded = await page.evaluate(async (names) => {
|
|
299
|
+
const entry = document.querySelector('script[type="module"][src]');
|
|
300
|
+
if (!entry) return 0;
|
|
301
|
+
const base = new URL(entry.src, location.href).href.replace(/assets\/[^/]+$/, "");
|
|
302
|
+
let done = 0;
|
|
303
|
+
await Promise.all(
|
|
304
|
+
names.map(
|
|
305
|
+
(name) =>
|
|
306
|
+
new Promise((resolve) => {
|
|
307
|
+
const link = document.createElement("link");
|
|
308
|
+
link.rel = "modulepreload";
|
|
309
|
+
link.crossOrigin = "anonymous";
|
|
310
|
+
link.href = base + name;
|
|
311
|
+
link.onload = () => {
|
|
312
|
+
done += 1;
|
|
313
|
+
resolve();
|
|
314
|
+
};
|
|
315
|
+
link.onerror = () => resolve();
|
|
316
|
+
document.head.appendChild(link);
|
|
317
|
+
}),
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
return done;
|
|
321
|
+
}, chunks);
|
|
322
|
+
if (loaded === 0) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`release evidence: preloaded 0 of ${chunks.length} route chunks — the ` +
|
|
325
|
+
"walkthrough would record the loading fallback instead of the screens",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
await page.waitForTimeout(250);
|
|
329
|
+
return { loaded, total: chunks.length };
|
|
330
|
+
}
|
|
331
|
+
|
|
45
332
|
async function main() {
|
|
46
333
|
await waitForServer();
|
|
47
334
|
|
|
@@ -93,6 +380,12 @@ async function main() {
|
|
|
93
380
|
await page.locator("main[aria-label='Lattice Brain']").waitFor();
|
|
94
381
|
await shot(page, "04-brain-chat-home.png");
|
|
95
382
|
|
|
383
|
+
const preload = await preloadRouteChunks(page);
|
|
384
|
+
console.log(
|
|
385
|
+
`release evidence: preloaded ${preload.loaded}/${preload.total} route chunks ` +
|
|
386
|
+
"so the walkthrough records screens, not the loading fallback",
|
|
387
|
+
);
|
|
388
|
+
|
|
96
389
|
await page.goto(`${baseURL}/app#/knowledge-graph`, { waitUntil: "networkidle" });
|
|
97
390
|
await page.locator("[data-testid='brain-cytoscape']").waitFor();
|
|
98
391
|
const graphSearch = page.locator(
|
|
@@ -176,10 +469,35 @@ async function main() {
|
|
|
176
469
|
gifTarget,
|
|
177
470
|
], { stdio: "ignore" });
|
|
178
471
|
|
|
472
|
+
const fingerprint = assetManifestFingerprint();
|
|
473
|
+
if (!fingerprint) {
|
|
474
|
+
throw new Error("asset-manifest.json missing after capture — cannot bind evidence");
|
|
475
|
+
}
|
|
476
|
+
const mockFp = mockServerFingerprint();
|
|
477
|
+
if (!mockFp) {
|
|
478
|
+
throw new Error("tests/visual/mock_server.cjs missing after capture — cannot bind evidence");
|
|
479
|
+
}
|
|
480
|
+
// Post-capture binding: if a later build:assets changes the manifest hash,
|
|
481
|
+
// or mock_server.cjs changes the capture payloads, check_release_evidence_bound.mjs
|
|
482
|
+
// will refuse to trust these screenshots without a recapture.
|
|
179
483
|
const index = `# v${version} Release Evidence
|
|
180
484
|
|
|
181
485
|
Captured from the built React/Vite app served by the release visual API on ${new Date().toISOString()}.
|
|
182
486
|
|
|
487
|
+
## Build Binding
|
|
488
|
+
|
|
489
|
+
Evidence is only trustworthy while this fingerprint matches
|
|
490
|
+
\`static/app/asset-manifest.json\` **and** \`tests/visual/mock_server.cjs\`.
|
|
491
|
+
A later \`build:assets\` or mock-server edit without recapture invalidates the
|
|
492
|
+
screenshots even when their mtimes look fresh.
|
|
493
|
+
|
|
494
|
+
- asset-manifest.sha256: \`${fingerprint.sha256}\`
|
|
495
|
+
- asset-manifest.mtime: \`${fingerprint.mtime}\`
|
|
496
|
+
- asset-manifest.bytes: ${fingerprint.bytes}
|
|
497
|
+
- mock-server.sha256: \`${mockFp.sha256}\`
|
|
498
|
+
- mock-server.mtime: \`${mockFp.mtime}\`
|
|
499
|
+
- mock-server.bytes: ${mockFp.bytes}
|
|
500
|
+
|
|
183
501
|
## Screenshots
|
|
184
502
|
|
|
185
503
|
| File | Flow |
|
|
@@ -203,6 +521,22 @@ Captured from the built React/Vite app served by the release visual API on ${new
|
|
|
203
521
|
- [v${version}-living-brain-walkthrough.gif](gifs/v${version}-living-brain-walkthrough.gif)
|
|
204
522
|
`;
|
|
205
523
|
fs.writeFileSync(path.join(root, "SCREENSHOT_INDEX.md"), index);
|
|
524
|
+
|
|
525
|
+
// Post-hoc: re-hash the manifest *now* and refuse if it drifted mid-run
|
|
526
|
+
// (e.g. a parallel build:assets while Playwright was shooting).
|
|
527
|
+
const after = assetManifestFingerprint();
|
|
528
|
+
if (!after || after.sha256 !== fingerprint.sha256) {
|
|
529
|
+
console.error(
|
|
530
|
+
"release evidence: asset-manifest.json changed during capture.\n" +
|
|
531
|
+
` bound: ${fingerprint.sha256}\n` +
|
|
532
|
+
` current: ${after ? after.sha256 : "(missing)"}\n` +
|
|
533
|
+
"Rebuild was not stable — delete evidence and re-run after a single build:assets.",
|
|
534
|
+
);
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
console.log(
|
|
538
|
+
`release evidence bound to asset-manifest sha256=${fingerprint.sha256.slice(0, 12)}… mtime=${fingerprint.mtime}`,
|
|
539
|
+
);
|
|
206
540
|
}
|
|
207
541
|
|
|
208
542
|
try {
|
|
@@ -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 = "Plain Surface";
|
|
10
10
|
const title = `${version} — ${releaseTheme}`;
|
|
11
11
|
const escapedVersion = version.replaceAll(".", "\\.");
|
|
12
12
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Post-capture evidence ↔ build binding gate.
|
|
4
|
+
*
|
|
5
|
+
* Failure mode
|
|
6
|
+
* ------------
|
|
7
|
+
* 1. `npm run release:evidence` writes screenshots (mtime T0).
|
|
8
|
+
* 2. Someone runs `npm run build:assets` again (manifest mtime T1 > T0).
|
|
9
|
+
* 3. The pre-capture guard only compares frontend/src mtime vs manifest mtime,
|
|
10
|
+
* so it still passes — while screenshots show an older UI than the build.
|
|
11
|
+
*
|
|
12
|
+
* Also: capture hits tests/visual/mock_server.cjs for API payloads. Changing
|
|
13
|
+
* the mock after capture (e.g. taller Act panel → 1846px vs committed 1721px)
|
|
14
|
+
* left asset-manifest.sha256 alone, so this gate used to stay green while
|
|
15
|
+
* SCREENSHOT_INDEX.md described a UI the tree no longer produces.
|
|
16
|
+
*
|
|
17
|
+
* Capture records asset-manifest.sha256 and mock-server.sha256 in
|
|
18
|
+
* SCREENSHOT_INDEX.md. This script fails (exit 1) when either binding is
|
|
19
|
+
* missing or does not match the live file. Wired into `npm run lint` so a
|
|
20
|
+
* green lint cannot ship stale screenshots.
|
|
21
|
+
*
|
|
22
|
+
* Exit 0 bound, 1 stale/missing, 2 could not run.
|
|
23
|
+
*
|
|
24
|
+
* Escape hatch: LTCAI_SKIP_RELEASE_EVIDENCE_BOUND=1 (used only by
|
|
25
|
+
* release:evidence while it intentionally wipes and rebinds evidence).
|
|
26
|
+
*/
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
|
|
31
|
+
const repoRoot = process.cwd();
|
|
32
|
+
|
|
33
|
+
if (process.env.LTCAI_SKIP_RELEASE_EVIDENCE_BOUND === "1") {
|
|
34
|
+
console.log(
|
|
35
|
+
"release evidence binding: skipped (LTCAI_SKIP_RELEASE_EVIDENCE_BOUND=1)",
|
|
36
|
+
);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const version = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
|
|
41
|
+
const root =
|
|
42
|
+
process.env.LTCAI_RELEASE_EVIDENCE_DIR ||
|
|
43
|
+
path.join(repoRoot, "output", "release", `v${version}`);
|
|
44
|
+
const indexPath = path.join(root, "SCREENSHOT_INDEX.md");
|
|
45
|
+
const manifestPath = path.join(repoRoot, "static", "app", "asset-manifest.json");
|
|
46
|
+
const mockServerPath = path.join(repoRoot, "tests", "visual", "mock_server.cjs");
|
|
47
|
+
|
|
48
|
+
function fail(message, code = 1) {
|
|
49
|
+
console.error(message);
|
|
50
|
+
process.exit(code);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sha256File(filePath) {
|
|
54
|
+
return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!fs.existsSync(indexPath)) {
|
|
58
|
+
fail(`release evidence binding: missing ${path.relative(repoRoot, indexPath)}`);
|
|
59
|
+
}
|
|
60
|
+
if (!fs.existsSync(manifestPath)) {
|
|
61
|
+
fail("release evidence binding: static/app/asset-manifest.json missing", 2);
|
|
62
|
+
}
|
|
63
|
+
if (!fs.existsSync(mockServerPath)) {
|
|
64
|
+
fail("release evidence binding: tests/visual/mock_server.cjs missing", 2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const index = fs.readFileSync(indexPath, "utf8");
|
|
68
|
+
const match = index.match(/asset-manifest\.sha256:\s*`?([0-9a-f]{64})`?/i);
|
|
69
|
+
if (!match) {
|
|
70
|
+
fail(
|
|
71
|
+
"release evidence binding: SCREENSHOT_INDEX.md has no asset-manifest.sha256.\n" +
|
|
72
|
+
" Re-run npm run release:evidence so the index records the build fingerprint.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const mockMatch = index.match(/mock-server\.sha256:\s*`?([0-9a-f]{64})`?/i);
|
|
77
|
+
if (!mockMatch) {
|
|
78
|
+
fail(
|
|
79
|
+
"release evidence binding: SCREENSHOT_INDEX.md has no mock-server.sha256.\n" +
|
|
80
|
+
" Capture data comes from tests/visual/mock_server.cjs; re-run\n" +
|
|
81
|
+
" npm run release:evidence so the index records the mock fingerprint.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const recorded = match[1].toLowerCase();
|
|
86
|
+
const current = sha256File(manifestPath);
|
|
87
|
+
const mtime = fs.statSync(manifestPath).mtime.toISOString();
|
|
88
|
+
|
|
89
|
+
if (recorded !== current) {
|
|
90
|
+
fail(
|
|
91
|
+
"release evidence binding: screenshots are stale relative to static/app.\n" +
|
|
92
|
+
` recorded sha256: ${recorded}\n` +
|
|
93
|
+
` current sha256: ${current}\n` +
|
|
94
|
+
` current mtime: ${mtime}\n` +
|
|
95
|
+
" Rebuild happened after capture. Run: npm run build:assets && npm run release:evidence",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const recordedMock = mockMatch[1].toLowerCase();
|
|
100
|
+
const currentMock = sha256File(mockServerPath);
|
|
101
|
+
const mockMtime = fs.statSync(mockServerPath).mtime.toISOString();
|
|
102
|
+
|
|
103
|
+
if (recordedMock !== currentMock) {
|
|
104
|
+
fail(
|
|
105
|
+
"release evidence binding: screenshots are stale relative to mock_server.cjs.\n" +
|
|
106
|
+
` recorded mock-server.sha256: ${recordedMock}\n` +
|
|
107
|
+
` current mock-server.sha256: ${currentMock}\n` +
|
|
108
|
+
` current mtime: ${mockMtime}\n` +
|
|
109
|
+
" Mock API payloads changed after capture. Run: npm run release:evidence\n" +
|
|
110
|
+
" (bump version first if vX.Y.Z is already tagged — see docs/OPERATIONS.md).",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(
|
|
115
|
+
`release evidence binding ok: asset=${current.slice(0, 12)}… mock=${currentMock.slice(0, 12)}… mtime=${mtime} dir=${path.relative(repoRoot, root)}`,
|
|
116
|
+
);
|