ltcai 10.6.3 → 10.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -59
- package/docs/CHANGELOG.md +68 -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/graph/retrieval_vector.py +69 -33
- package/lattice_brain/runtime/multi_agent.py +96 -15
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +81 -6
- package/latticeai/api/auth.py +37 -38
- package/latticeai/api/automation_intelligence.py +112 -0
- package/latticeai/api/browser.py +7 -6
- package/latticeai/api/knowledge_graph.py +133 -0
- package/latticeai/api/static_routes.py +35 -1
- package/latticeai/core/file_generation.py +141 -8
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +230 -0
- package/latticeai/core/workspace_computer_memory.py +84 -0
- package/latticeai/core/workspace_indexing.py +102 -0
- package/latticeai/core/workspace_onboarding.py +104 -0
- package/latticeai/core/workspace_os.py +27 -207
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_relationships.py +99 -0
- 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 +517 -0
- package/scripts/measure_brain_home_fill.mjs +141 -0
- package/scripts/release_screen_claims.json +37 -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-05uZSgyw.js → Act-CbQNsFgc.js} +1 -1
- package/static/app/assets/AdminConsole-Bs8SR8fE.js +1 -0
- package/static/app/assets/{Brain-jjMYTdnU.js → Brain-BZIPsZlH.js} +2 -2
- package/static/app/assets/BrainHome-BaH5MDce.js +2 -0
- package/static/app/assets/{BrainSignals-BYxDqBH3.js → BrainSignals-aDt-nSsW.js} +1 -1
- package/static/app/assets/Capture-DDv940Bv.js +1 -0
- package/static/app/assets/{CommandPalette-Cj7z-qUE.js → CommandPalette-Dq_MmPBl.js} +1 -1
- package/static/app/assets/Library-BkShbqmN.js +1 -0
- package/static/app/assets/{LivingBrain-CTokkZn1.js → LivingBrain-CXb8094H.js} +1 -1
- package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
- package/static/app/assets/{ReviewCard-uAII_l9v.js → ReviewCard-Be7E7X18.js} +2 -2
- package/static/app/assets/System-DCstbTxC.js +1 -0
- package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
- package/static/app/assets/{bot-CkrHFdUl.js → bot-BR0eoeXY.js} +1 -1
- package/static/app/assets/brain-BmWa1wea.js +1 -0
- package/static/app/assets/{button-Ic6omIbs.js → button-U1nU7xX4.js} +1 -1
- package/static/app/assets/{circle-pause-DBaaUR08.js → circle-pause-DG5KHjDL.js} +1 -1
- package/static/app/assets/{circle-play-Dz0OLYN9.js → circle-play-Chhcuq90.js} +1 -1
- package/static/app/assets/{cpu-N8uE9ono.js → cpu-pkOSlT9h.js} +1 -1
- package/static/app/assets/{download-CCjhOjms.js → download-CSRwz923.js} +1 -1
- package/static/app/assets/{folder-open-Bld9TtHp.js → folder-open-DiMJ7W_7.js} +1 -1
- package/static/app/assets/{hard-drive-BMIqDSyY.js → hard-drive-iyLPAPdp.js} +1 -1
- package/static/app/assets/index-BZRvGOM2.css +2 -0
- package/static/app/assets/{index-yNtiRyw3.js → index-_ldE9TAX.js} +4 -4
- package/static/app/assets/{input-DW1tXtc0.js → input-e_r2hFAY.js} +1 -1
- package/static/app/assets/{permissionCopy-Djpwn54H.js → permissionCopy-9uOHktz3.js} +1 -1
- package/static/app/assets/{primitives-B6WtkLgB.js → primitives-CnEAOmg2.js} +1 -1
- package/static/app/assets/search-veep4n6f.js +1 -0
- package/static/app/assets/{share-2-DR5L1qRc.js → share-2-DLDJJKmI.js} +1 -1
- package/static/app/assets/{shield-alert-CVE5eXsl.js → shield-alert-BTtE-2EZ.js} +1 -1
- package/static/app/assets/{textarea-DdaVFgvk.js → textarea-Bbzkcgqe.js} +1 -1
- package/static/app/assets/{useFocusTrap-B5TyGaiB.js → useFocusTrap-DJzQd-Za.js} +1 -1
- package/static/app/assets/{useQuery-CiRdTQlD.js → useQuery-6R_tqHRe.js} +1 -1
- package/static/app/assets/utils-XDsjcrhS.js +4 -0
- package/static/app/assets/workspace-CwrG8LHq.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/AdminConsole-C8yz1Cs_.js +0 -1
- package/static/app/assets/BrainHome-D3t45_D4.js +0 -2
- package/static/app/assets/Capture-DKgu7fB0.js +0 -1
- package/static/app/assets/Library-CO76FWeO.js +0 -1
- package/static/app/assets/ProductFlow-Bmted2ys.js +0 -1
- package/static/app/assets/System-PUMzDmof.js +0 -1
- package/static/app/assets/activity-C_7182GG.js +0 -1
- package/static/app/assets/arrow-left-BQCpWR37.js +0 -1
- package/static/app/assets/brain-CdPX7svt.js +0 -1
- package/static/app/assets/index-DYUs0cWy.css +0 -2
- package/static/app/assets/search-CkHYuKpY.js +0 -1
- package/static/app/assets/users-pZqCRxdD.js +0 -1
- package/static/app/assets/utils-C3rXoa-U.js +0 -4
- package/static/app/assets/workspace-DfxKEttN.js +0 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""First-run onboarding progress.
|
|
2
|
+
|
|
3
|
+
Extracted from ``WorkspaceOSStore``. Owns the ``onboarding`` branch of the
|
|
4
|
+
state document: which named step the person is on, what each step recorded, and
|
|
5
|
+
whether the whole run is finished.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from .timeutil import now_iso as _now
|
|
13
|
+
from .workspace_os_constants import ONBOARDING_STEPS
|
|
14
|
+
|
|
15
|
+
__all__ = ["WorkspaceOnboarding", "ONBOARDING_STATUSES"]
|
|
16
|
+
|
|
17
|
+
#: The statuses a single step may hold. Anything else is a caller bug, not a
|
|
18
|
+
#: value to store — an unknown status would silently stall the flow, because
|
|
19
|
+
#: `current_step` only advances on `complete`/`skipped`.
|
|
20
|
+
ONBOARDING_STATUSES = frozenset({"pending", "running", "complete", "failed", "skipped"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WorkspaceOnboarding:
|
|
24
|
+
"""Tracks the named first-run steps and where the person stopped."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, store: Any) -> None:
|
|
27
|
+
self.store = store
|
|
28
|
+
|
|
29
|
+
def status(
|
|
30
|
+
self,
|
|
31
|
+
users: Optional[Dict[str, Any]] = None,
|
|
32
|
+
graph_stats: Optional[Dict[str, Any]] = None,
|
|
33
|
+
) -> Dict[str, Any]:
|
|
34
|
+
state = self.store.load_state()
|
|
35
|
+
users = users or {}
|
|
36
|
+
admins = [
|
|
37
|
+
email for email, user in users.items()
|
|
38
|
+
if isinstance(user, dict) and user.get("role") == "admin"
|
|
39
|
+
]
|
|
40
|
+
onboarding = state.get("onboarding") or {}
|
|
41
|
+
steps = onboarding.get("steps") or {}
|
|
42
|
+
return {
|
|
43
|
+
**onboarding,
|
|
44
|
+
"steps": [steps.get(step, {"id": step, "status": "pending"}) for step in ONBOARDING_STEPS],
|
|
45
|
+
"has_account": bool(users),
|
|
46
|
+
"has_admin": bool(admins) or bool(users),
|
|
47
|
+
"graph_ready": bool(graph_stats and not graph_stats.get("disabled")),
|
|
48
|
+
"required_steps": list(ONBOARDING_STEPS),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def update_step(
|
|
52
|
+
self,
|
|
53
|
+
step: str,
|
|
54
|
+
*,
|
|
55
|
+
status: str = "complete",
|
|
56
|
+
data: Optional[Dict[str, Any]] = None,
|
|
57
|
+
error: str = "",
|
|
58
|
+
user_email: Optional[str] = None,
|
|
59
|
+
) -> Dict[str, Any]:
|
|
60
|
+
if step not in ONBOARDING_STEPS:
|
|
61
|
+
raise ValueError(f"unknown onboarding step: {step}")
|
|
62
|
+
if status not in ONBOARDING_STATUSES:
|
|
63
|
+
raise ValueError(f"unknown onboarding status: {status}")
|
|
64
|
+
state = self.store.load_state()
|
|
65
|
+
onboarding = state.setdefault("onboarding", {})
|
|
66
|
+
steps = onboarding.setdefault("steps", {})
|
|
67
|
+
record = steps.setdefault(step, {"id": step})
|
|
68
|
+
record.update({
|
|
69
|
+
"id": step,
|
|
70
|
+
"status": status,
|
|
71
|
+
"data": data or record.get("data") or {},
|
|
72
|
+
"error": error,
|
|
73
|
+
"updated_at": _now(),
|
|
74
|
+
"user_email": user_email,
|
|
75
|
+
})
|
|
76
|
+
if status in {"complete", "skipped"}:
|
|
77
|
+
index = ONBOARDING_STEPS.index(step)
|
|
78
|
+
if step == "complete":
|
|
79
|
+
onboarding["completed"] = True
|
|
80
|
+
onboarding["completed_at"] = _now()
|
|
81
|
+
onboarding["current_step"] = "complete"
|
|
82
|
+
elif index + 1 < len(ONBOARDING_STEPS):
|
|
83
|
+
onboarding["current_step"] = ONBOARDING_STEPS[index + 1]
|
|
84
|
+
elif status == "failed":
|
|
85
|
+
onboarding["current_step"] = step
|
|
86
|
+
self.store.save_state(state)
|
|
87
|
+
self.store.record_timeline_event(
|
|
88
|
+
"workspace", "onboarding_step", {"step": step, "status": status}
|
|
89
|
+
)
|
|
90
|
+
return self.status()
|
|
91
|
+
|
|
92
|
+
def complete(
|
|
93
|
+
self,
|
|
94
|
+
data: Optional[Dict[str, Any]] = None,
|
|
95
|
+
user_email: Optional[str] = None,
|
|
96
|
+
) -> Dict[str, Any]:
|
|
97
|
+
for step in ONBOARDING_STEPS:
|
|
98
|
+
self.update_step(
|
|
99
|
+
step,
|
|
100
|
+
status="complete",
|
|
101
|
+
data=data if step == "complete" else None,
|
|
102
|
+
user_email=user_email,
|
|
103
|
+
)
|
|
104
|
+
return self.status()
|
|
@@ -11,7 +11,6 @@ from __future__ import annotations
|
|
|
11
11
|
import json
|
|
12
12
|
import shutil
|
|
13
13
|
import sqlite3
|
|
14
|
-
from collections import deque
|
|
15
14
|
from contextlib import contextmanager
|
|
16
15
|
from pathlib import Path
|
|
17
16
|
from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional
|
|
@@ -19,8 +18,11 @@ from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional
|
|
|
19
18
|
from latticeai.core.quiet import quiet
|
|
20
19
|
|
|
21
20
|
from .timeutil import now_iso as _now
|
|
21
|
+
from .workspace_computer_memory import WorkspaceComputerMemory
|
|
22
22
|
from .workspace_graph_trace import WorkspaceGraphTrace
|
|
23
|
+
from .workspace_indexing import WorkspaceIndexing
|
|
23
24
|
from .workspace_memory import WorkspaceMemory
|
|
25
|
+
from .workspace_onboarding import WorkspaceOnboarding
|
|
24
26
|
from .workspace_os_constants import (
|
|
25
27
|
DEFAULT_WORKSPACE_ID,
|
|
26
28
|
EXECUTION_EVENT_TYPES,
|
|
@@ -49,6 +51,8 @@ from .workspace_permissions import ( # type: ignore
|
|
|
49
51
|
_member_role,
|
|
50
52
|
)
|
|
51
53
|
from .workspace_plugins import WorkspacePluginManager
|
|
54
|
+
from .workspace_relationships import WorkspaceRelationships
|
|
55
|
+
from .workspace_relationships import shortest_path as _shortest_path
|
|
52
56
|
from .workspace_review_items import WorkspaceReviewItems
|
|
53
57
|
from .workspace_runs import WorkspaceRuns
|
|
54
58
|
from .workspace_skills import WorkspaceSkills
|
|
@@ -108,6 +112,10 @@ class WorkspaceOSStore:
|
|
|
108
112
|
self.runs = WorkspaceRuns(self)
|
|
109
113
|
self.review_items = WorkspaceReviewItems(self)
|
|
110
114
|
self.skills = WorkspaceSkills(self)
|
|
115
|
+
self.indexing = WorkspaceIndexing(self)
|
|
116
|
+
self.relationships = WorkspaceRelationships(self)
|
|
117
|
+
self.computer_memory = WorkspaceComputerMemory(self)
|
|
118
|
+
self.onboarding = WorkspaceOnboarding(self)
|
|
111
119
|
|
|
112
120
|
@contextmanager
|
|
113
121
|
def _connect_state_db(self) -> Iterator[sqlite3.Connection]:
|
|
@@ -552,22 +560,7 @@ class WorkspaceOSStore:
|
|
|
552
560
|
# ------------------------------------------------------------------
|
|
553
561
|
|
|
554
562
|
def onboarding_status(self, users: Optional[Dict[str, Any]] = None, graph_stats: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
555
|
-
|
|
556
|
-
users = users or {}
|
|
557
|
-
admins = [
|
|
558
|
-
email for email, user in users.items()
|
|
559
|
-
if isinstance(user, dict) and user.get("role") == "admin"
|
|
560
|
-
]
|
|
561
|
-
onboarding = state.get("onboarding") or {}
|
|
562
|
-
steps = onboarding.get("steps") or {}
|
|
563
|
-
return {
|
|
564
|
-
**onboarding,
|
|
565
|
-
"steps": [steps.get(step, {"id": step, "status": "pending"}) for step in ONBOARDING_STEPS],
|
|
566
|
-
"has_account": bool(users),
|
|
567
|
-
"has_admin": bool(admins) or bool(users),
|
|
568
|
-
"graph_ready": bool(graph_stats and not graph_stats.get("disabled")),
|
|
569
|
-
"required_steps": list(ONBOARDING_STEPS),
|
|
570
|
-
}
|
|
563
|
+
return self.onboarding.status(users=users, graph_stats=graph_stats)
|
|
571
564
|
|
|
572
565
|
def update_onboarding_step(
|
|
573
566
|
self,
|
|
@@ -578,40 +571,12 @@ class WorkspaceOSStore:
|
|
|
578
571
|
error: str = "",
|
|
579
572
|
user_email: Optional[str] = None,
|
|
580
573
|
) -> Dict[str, Any]:
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
raise ValueError(f"unknown onboarding status: {status}")
|
|
585
|
-
state = self.load_state()
|
|
586
|
-
onboarding = state.setdefault("onboarding", {})
|
|
587
|
-
steps = onboarding.setdefault("steps", {})
|
|
588
|
-
record = steps.setdefault(step, {"id": step})
|
|
589
|
-
record.update({
|
|
590
|
-
"id": step,
|
|
591
|
-
"status": status,
|
|
592
|
-
"data": data or record.get("data") or {},
|
|
593
|
-
"error": error,
|
|
594
|
-
"updated_at": _now(),
|
|
595
|
-
"user_email": user_email,
|
|
596
|
-
})
|
|
597
|
-
if status in {"complete", "skipped"}:
|
|
598
|
-
index = ONBOARDING_STEPS.index(step)
|
|
599
|
-
if step == "complete":
|
|
600
|
-
onboarding["completed"] = True
|
|
601
|
-
onboarding["completed_at"] = _now()
|
|
602
|
-
onboarding["current_step"] = "complete"
|
|
603
|
-
elif index + 1 < len(ONBOARDING_STEPS):
|
|
604
|
-
onboarding["current_step"] = ONBOARDING_STEPS[index + 1]
|
|
605
|
-
elif status == "failed":
|
|
606
|
-
onboarding["current_step"] = step
|
|
607
|
-
self.save_state(state)
|
|
608
|
-
self.record_timeline_event("workspace", "onboarding_step", {"step": step, "status": status})
|
|
609
|
-
return self.onboarding_status()
|
|
574
|
+
return self.onboarding.update_step(
|
|
575
|
+
step, status=status, data=data, error=error, user_email=user_email
|
|
576
|
+
)
|
|
610
577
|
|
|
611
578
|
def complete_onboarding(self, data: Optional[Dict[str, Any]] = None, user_email: Optional[str] = None) -> Dict[str, Any]:
|
|
612
|
-
|
|
613
|
-
self.update_onboarding_step(step, status="complete", data=data if step == "complete" else None, user_email=user_email)
|
|
614
|
-
return self.onboarding_status()
|
|
579
|
+
return self.onboarding.complete(data=data, user_email=user_email)
|
|
615
580
|
|
|
616
581
|
# ------------------------------------------------------------------
|
|
617
582
|
# Graph answer traces
|
|
@@ -645,76 +610,16 @@ class WorkspaceOSStore:
|
|
|
645
610
|
# ------------------------------------------------------------------
|
|
646
611
|
|
|
647
612
|
def build_indexing_dashboard(self, graph: Any, watcher_status: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
648
|
-
|
|
649
|
-
return {
|
|
650
|
-
"sources": [],
|
|
651
|
-
"watcher": watcher_status or {"available": False, "active": {}},
|
|
652
|
-
"totals": {"success": 0, "failed": 0, "nodes": 0, "edges": 0},
|
|
653
|
-
}
|
|
654
|
-
stats = graph.stats()
|
|
655
|
-
sources = graph.local_sources().get("sources", [])
|
|
656
|
-
watcher_status = watcher_status or {"available": False, "active": {}}
|
|
657
|
-
active = watcher_status.get("active", {})
|
|
658
|
-
dashboard_sources = []
|
|
659
|
-
total_success = 0
|
|
660
|
-
total_failed = 0
|
|
661
|
-
for source in sources:
|
|
662
|
-
file_status = source.get("file_status") or {}
|
|
663
|
-
success = int(file_status.get("indexed") or 0)
|
|
664
|
-
failed = sum(int(file_status.get(key) or 0) for key in ("failed", "inaccessible", "skipped_empty_text"))
|
|
665
|
-
total_success += success
|
|
666
|
-
total_failed += failed
|
|
667
|
-
watch = active.get(source.get("id")) or {}
|
|
668
|
-
dashboard_sources.append({
|
|
669
|
-
"id": source.get("id"),
|
|
670
|
-
"label": source.get("label"),
|
|
671
|
-
"root_path": source.get("root_path"),
|
|
672
|
-
"status": source.get("status"),
|
|
673
|
-
"watch_enabled": bool(source.get("watch_enabled")),
|
|
674
|
-
"watch_active": source.get("id") in active,
|
|
675
|
-
"watch_status": watch,
|
|
676
|
-
"success_count": success,
|
|
677
|
-
"failure_count": failed,
|
|
678
|
-
"last_run_at": source.get("last_scanned_at") or source.get("updated_at"),
|
|
679
|
-
"file_status": file_status,
|
|
680
|
-
"include_ocr": bool(source.get("include_ocr")),
|
|
681
|
-
})
|
|
682
|
-
return {
|
|
683
|
-
"sources": dashboard_sources,
|
|
684
|
-
"watcher": watcher_status,
|
|
685
|
-
"totals": {
|
|
686
|
-
"success": total_success,
|
|
687
|
-
"failed": total_failed,
|
|
688
|
-
"nodes": sum(int(v or 0) for v in (stats.get("nodes") or {}).values()),
|
|
689
|
-
"edges": sum(int(v or 0) for v in (stats.get("edges") or {}).values()),
|
|
690
|
-
"local_sources": stats.get("local_sources", len(sources)),
|
|
691
|
-
},
|
|
692
|
-
"graph_stats": stats,
|
|
693
|
-
}
|
|
613
|
+
return self.indexing.build_dashboard(graph, watcher_status)
|
|
694
614
|
|
|
695
615
|
def pause_indexing(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
|
|
696
|
-
|
|
697
|
-
watch = watcher.stop_source(source_id) if watcher else {"stopped": False, "source_id": source_id}
|
|
698
|
-
self.record_timeline_event("graph", "indexing_paused", {"source_id": source_id})
|
|
699
|
-
return {"status": "ok", "source": result, "watch": watch}
|
|
616
|
+
return self.indexing.pause(graph, source_id, watcher)
|
|
700
617
|
|
|
701
618
|
def resume_indexing(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
|
|
702
|
-
|
|
703
|
-
watch = {"watching": False, "source_id": source_id}
|
|
704
|
-
source = next((item for item in graph.local_sources().get("sources", []) if item.get("id") == source_id), None)
|
|
705
|
-
if watcher and source:
|
|
706
|
-
watch = watcher.start_source(source)
|
|
707
|
-
self.record_timeline_event("graph", "indexing_resumed", {"source_id": source_id})
|
|
708
|
-
return {"status": "ok", "source": result, "watch": watch}
|
|
619
|
+
return self.indexing.resume(graph, source_id, watcher)
|
|
709
620
|
|
|
710
621
|
def remove_index_source(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
|
|
711
|
-
|
|
712
|
-
watcher.stop_source(source_id)
|
|
713
|
-
if not hasattr(graph, "remove_local_source"):
|
|
714
|
-
raise ValueError("graph store does not support removing local sources")
|
|
715
|
-
result = graph.remove_local_source(source_id)
|
|
716
|
-
self.record_timeline_event("graph", "indexing_removed", {"source_id": source_id})
|
|
717
|
-
return {"status": "ok", **result}
|
|
622
|
+
return self.indexing.remove_source(graph, source_id, watcher)
|
|
718
623
|
|
|
719
624
|
# ------------------------------------------------------------------
|
|
720
625
|
# Snapshots, Time Machine, and diffs
|
|
@@ -883,6 +788,9 @@ class WorkspaceOSStore:
|
|
|
883
788
|
def list_workflow_runs(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
|
|
884
789
|
return self.runs.list_workflow_runs(*args, **kwargs)
|
|
885
790
|
|
|
791
|
+
def list_combined_runs(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
|
|
792
|
+
return self.runs.list_combined_runs(*args, **kwargs)
|
|
793
|
+
|
|
886
794
|
def mark_workflow_run_resolved(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
|
|
887
795
|
return self.runs.mark_workflow_run_resolved(*args, **kwargs)
|
|
888
796
|
|
|
@@ -936,63 +844,11 @@ class WorkspaceOSStore:
|
|
|
936
844
|
# ------------------------------------------------------------------
|
|
937
845
|
|
|
938
846
|
def relationship_explorer(self, graph: Any, node_id: str, target_id: Optional[str] = None, limit: int = 500) -> Dict[str, Any]:
|
|
939
|
-
|
|
940
|
-
return {"node_id": node_id, "inbound": [], "outbound": [], "related_entities": [], "shortest_path": []}
|
|
941
|
-
data = graph.graph(limit=limit)
|
|
942
|
-
nodes = {node.get("id"): node for node in data.get("nodes") or [] if node.get("id")}
|
|
943
|
-
edges = data.get("edges") or []
|
|
944
|
-
inbound = [edge for edge in edges if edge.get("to") == node_id]
|
|
945
|
-
outbound = [edge for edge in edges if edge.get("from") == node_id]
|
|
946
|
-
if node_id not in nodes:
|
|
947
|
-
try:
|
|
948
|
-
neighbors = graph.neighbors(node_id)
|
|
949
|
-
for node in neighbors.get("neighbors") or []:
|
|
950
|
-
nodes[node.get("id")] = node
|
|
951
|
-
edges.extend(neighbors.get("edges") or [])
|
|
952
|
-
inbound = [edge for edge in edges if edge.get("to") == node_id]
|
|
953
|
-
outbound = [edge for edge in edges if edge.get("from") == node_id]
|
|
954
|
-
except Exception:
|
|
955
|
-
quiet()
|
|
956
|
-
|
|
957
|
-
related_ids = []
|
|
958
|
-
for edge in inbound + outbound:
|
|
959
|
-
other = edge.get("from") if edge.get("to") == node_id else edge.get("to")
|
|
960
|
-
if other:
|
|
961
|
-
related_ids.append(other)
|
|
962
|
-
related = [nodes.get(rid, {"id": rid}) for rid in dict.fromkeys(related_ids)]
|
|
963
|
-
shortest_path = self._shortest_path(edges, node_id, target_id) if target_id else []
|
|
964
|
-
return {
|
|
965
|
-
"node_id": node_id,
|
|
966
|
-
"node": nodes.get(node_id, {"id": node_id}),
|
|
967
|
-
"inbound": inbound,
|
|
968
|
-
"outbound": outbound,
|
|
969
|
-
"related_entities": related,
|
|
970
|
-
"shortest_path": shortest_path,
|
|
971
|
-
}
|
|
847
|
+
return self.relationships.explore(graph, node_id, target_id=target_id, limit=limit)
|
|
972
848
|
|
|
973
849
|
@staticmethod
|
|
974
850
|
def _shortest_path(edges: List[Dict[str, Any]], start: str, target: Optional[str]) -> List[str]:
|
|
975
|
-
|
|
976
|
-
return []
|
|
977
|
-
adjacency: Dict[str, List[str]] = {}
|
|
978
|
-
for edge in edges:
|
|
979
|
-
src = edge.get("from")
|
|
980
|
-
dst = edge.get("to")
|
|
981
|
-
if src and dst:
|
|
982
|
-
adjacency.setdefault(src, []).append(dst)
|
|
983
|
-
adjacency.setdefault(dst, []).append(src)
|
|
984
|
-
queue: deque[List[str]] = deque([[start]])
|
|
985
|
-
seen = {start}
|
|
986
|
-
while queue:
|
|
987
|
-
path = queue.popleft()
|
|
988
|
-
node = path[-1]
|
|
989
|
-
if node == target:
|
|
990
|
-
return path
|
|
991
|
-
for neighbor in adjacency.get(node, []):
|
|
992
|
-
if neighbor not in seen:
|
|
993
|
-
seen.add(neighbor)
|
|
994
|
-
queue.append(path + [neighbor])
|
|
995
|
-
return []
|
|
851
|
+
return _shortest_path(edges, start, target)
|
|
996
852
|
|
|
997
853
|
# ------------------------------------------------------------------
|
|
998
854
|
# Local Computer Memory
|
|
@@ -1006,48 +862,12 @@ class WorkspaceOSStore:
|
|
|
1006
862
|
consent: Optional[Dict[str, Any]] = None,
|
|
1007
863
|
scopes: Optional[List[str]] = None,
|
|
1008
864
|
) -> Dict[str, Any]:
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
state = self.load_state()
|
|
1013
|
-
config = state.setdefault("computer_memory", {})
|
|
1014
|
-
config.update({
|
|
1015
|
-
"enabled": bool(enabled),
|
|
1016
|
-
"approved": bool(enabled),
|
|
1017
|
-
"approved_at": _now() if enabled else config.get("approved_at"),
|
|
1018
|
-
"approved_by": approved_by if enabled else config.get("approved_by"),
|
|
1019
|
-
"scopes": scopes or config.get("scopes") or ["Downloads", "Documents", "Repositories"],
|
|
1020
|
-
"consent": consent,
|
|
1021
|
-
})
|
|
1022
|
-
state.setdefault("feature_flags", {})["local_computer_memory"] = bool(enabled)
|
|
1023
|
-
self.save_state(state)
|
|
1024
|
-
self.record_timeline_event("memory", "computer_memory_configured", {"enabled": bool(enabled), "approved_by": approved_by})
|
|
1025
|
-
return config
|
|
865
|
+
return self.computer_memory.configure(
|
|
866
|
+
enabled=enabled, approved_by=approved_by, consent=consent, scopes=scopes
|
|
867
|
+
)
|
|
1026
868
|
|
|
1027
869
|
def record_computer_activity(self, activity: Dict[str, Any], graph: Any = None) -> Dict[str, Any]:
|
|
1028
|
-
|
|
1029
|
-
config = state.setdefault("computer_memory", {})
|
|
1030
|
-
if not config.get("enabled"):
|
|
1031
|
-
return {"status": "ignored", "reason": "local computer memory is disabled"}
|
|
1032
|
-
record = {
|
|
1033
|
-
"id": f"activity-{_json_hash([activity, _now()])[:16]}",
|
|
1034
|
-
"timestamp": _now(),
|
|
1035
|
-
**activity,
|
|
1036
|
-
}
|
|
1037
|
-
config.setdefault("activities", []).append(record)
|
|
1038
|
-
if graph is not None:
|
|
1039
|
-
try:
|
|
1040
|
-
graph.ingest_event(
|
|
1041
|
-
"ComputerActivity",
|
|
1042
|
-
str(activity.get("summary") or activity.get("path") or "Computer activity")[:120],
|
|
1043
|
-
source="workspace_os",
|
|
1044
|
-
metadata=record,
|
|
1045
|
-
)
|
|
1046
|
-
except Exception as exc:
|
|
1047
|
-
record["graph_error"] = str(exc)
|
|
1048
|
-
self.save_state(state)
|
|
1049
|
-
self.record_timeline_event("memory", "computer_activity", {"activity_id": record["id"]})
|
|
1050
|
-
return {"status": "ok", "activity": record}
|
|
870
|
+
return self.computer_memory.record_activity(activity, graph)
|
|
1051
871
|
|
|
1052
872
|
# ------------------------------------------------------------------
|
|
1053
873
|
# Skills
|
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
from typing import Dict
|
|
12
12
|
|
|
13
|
-
WORKSPACE_OS_VERSION = "10.
|
|
13
|
+
WORKSPACE_OS_VERSION = "10.8.0"
|
|
14
14
|
|
|
15
15
|
# Workspace types separate single-user Personal workspaces from shared
|
|
16
16
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Relationship explorer: what a node connects to, and the way between two.
|
|
2
|
+
|
|
3
|
+
Extracted from ``WorkspaceOSStore``. Pure graph reading — it touches no state
|
|
4
|
+
file and records no events, so it holds no reference to the store at all.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import deque
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from latticeai.core.quiet import quiet
|
|
13
|
+
|
|
14
|
+
__all__ = ["WorkspaceRelationships", "shortest_path"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def shortest_path(edges: List[Dict[str, Any]], start: str, target: Optional[str]) -> List[str]:
|
|
18
|
+
"""Fewest-hops path between two nodes, treating every edge as undirected.
|
|
19
|
+
|
|
20
|
+
Breadth-first, so the first arrival at ``target`` is a shortest one.
|
|
21
|
+
Returns ``[]`` when either end is missing or nothing connects them.
|
|
22
|
+
"""
|
|
23
|
+
if not start or not target:
|
|
24
|
+
return []
|
|
25
|
+
adjacency: Dict[str, List[str]] = {}
|
|
26
|
+
for edge in edges:
|
|
27
|
+
src = edge.get("from")
|
|
28
|
+
dst = edge.get("to")
|
|
29
|
+
if src and dst:
|
|
30
|
+
adjacency.setdefault(src, []).append(dst)
|
|
31
|
+
adjacency.setdefault(dst, []).append(src)
|
|
32
|
+
queue: deque[List[str]] = deque([[start]])
|
|
33
|
+
seen = {start}
|
|
34
|
+
while queue:
|
|
35
|
+
path = queue.popleft()
|
|
36
|
+
node = path[-1]
|
|
37
|
+
if node == target:
|
|
38
|
+
return path
|
|
39
|
+
for neighbor in adjacency.get(node, []):
|
|
40
|
+
if neighbor not in seen:
|
|
41
|
+
seen.add(neighbor)
|
|
42
|
+
queue.append(path + [neighbor])
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WorkspaceRelationships:
|
|
47
|
+
"""Answers "what is this connected to" for one node of the graph."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, store: Any) -> None:
|
|
50
|
+
self.store = store
|
|
51
|
+
|
|
52
|
+
def explore(
|
|
53
|
+
self,
|
|
54
|
+
graph: Any,
|
|
55
|
+
node_id: str,
|
|
56
|
+
target_id: Optional[str] = None,
|
|
57
|
+
limit: int = 500,
|
|
58
|
+
) -> Dict[str, Any]:
|
|
59
|
+
if graph is None:
|
|
60
|
+
return {
|
|
61
|
+
"node_id": node_id,
|
|
62
|
+
"inbound": [],
|
|
63
|
+
"outbound": [],
|
|
64
|
+
"related_entities": [],
|
|
65
|
+
"shortest_path": [],
|
|
66
|
+
}
|
|
67
|
+
data = graph.graph(limit=limit)
|
|
68
|
+
nodes = {node.get("id"): node for node in data.get("nodes") or [] if node.get("id")}
|
|
69
|
+
edges = data.get("edges") or []
|
|
70
|
+
inbound = [edge for edge in edges if edge.get("to") == node_id]
|
|
71
|
+
outbound = [edge for edge in edges if edge.get("from") == node_id]
|
|
72
|
+
# The capped `graph(limit=…)` window may not contain this node at all.
|
|
73
|
+
# Asking the store for its neighbours directly is the difference between
|
|
74
|
+
# "not connected" and "outside the page we happened to fetch".
|
|
75
|
+
if node_id not in nodes:
|
|
76
|
+
try:
|
|
77
|
+
neighbors = graph.neighbors(node_id)
|
|
78
|
+
for node in neighbors.get("neighbors") or []:
|
|
79
|
+
nodes[node.get("id")] = node
|
|
80
|
+
edges.extend(neighbors.get("edges") or [])
|
|
81
|
+
inbound = [edge for edge in edges if edge.get("to") == node_id]
|
|
82
|
+
outbound = [edge for edge in edges if edge.get("from") == node_id]
|
|
83
|
+
except Exception:
|
|
84
|
+
quiet()
|
|
85
|
+
|
|
86
|
+
related_ids = []
|
|
87
|
+
for edge in inbound + outbound:
|
|
88
|
+
other = edge.get("from") if edge.get("to") == node_id else edge.get("to")
|
|
89
|
+
if other:
|
|
90
|
+
related_ids.append(other)
|
|
91
|
+
related = [nodes.get(rid, {"id": rid}) for rid in dict.fromkeys(related_ids)]
|
|
92
|
+
return {
|
|
93
|
+
"node_id": node_id,
|
|
94
|
+
"node": nodes.get(node_id, {"id": node_id}),
|
|
95
|
+
"inbound": inbound,
|
|
96
|
+
"outbound": outbound,
|
|
97
|
+
"related_entities": related,
|
|
98
|
+
"shortest_path": shortest_path(edges, node_id, target_id) if target_id else [],
|
|
99
|
+
}
|
|
@@ -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,
|