ltcai 5.5.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +43 -24
  2. package/docs/CHANGELOG.md +69 -0
  3. package/frontend/openapi.json +716 -3
  4. package/frontend/src/api/client.ts +119 -2
  5. package/frontend/src/api/openapi.ts +621 -4
  6. package/frontend/src/components/FirstRunGuide.tsx +3 -3
  7. package/frontend/src/features/review/ReviewCard.tsx +91 -0
  8. package/frontend/src/features/review/ReviewInbox.tsx +112 -0
  9. package/frontend/src/features/review/reviewHelpers.ts +69 -0
  10. package/frontend/src/i18n.ts +8 -8
  11. package/frontend/src/pages/Act.tsx +28 -3
  12. package/frontend/src/routes.ts +2 -0
  13. package/lattice_brain/__init__.py +1 -1
  14. package/lattice_brain/runtime/multi_agent.py +1 -1
  15. package/latticeai/__init__.py +1 -1
  16. package/latticeai/api/review_queue.py +162 -0
  17. package/latticeai/app_factory.py +235 -456
  18. package/latticeai/core/marketplace.py +1 -1
  19. package/latticeai/core/workspace_os.py +86 -1
  20. package/latticeai/runtime/app_context_runtime.py +13 -0
  21. package/latticeai/runtime/automation_runtime.py +64 -0
  22. package/latticeai/runtime/bootstrap.py +48 -0
  23. package/latticeai/runtime/context_runtime.py +43 -0
  24. package/latticeai/runtime/hooks_runtime.py +77 -0
  25. package/latticeai/runtime/lifespan_runtime.py +138 -0
  26. package/latticeai/runtime/persistence_runtime.py +87 -0
  27. package/latticeai/runtime/platform_services_runtime.py +39 -0
  28. package/latticeai/runtime/router_registration.py +570 -0
  29. package/latticeai/runtime/web_runtime.py +65 -0
  30. package/latticeai/services/review_queue.py +271 -0
  31. package/latticeai/services/run_executor.py +33 -0
  32. package/latticeai/services/triggers.py +30 -1
  33. package/package.json +1 -1
  34. package/src-tauri/Cargo.lock +1 -1
  35. package/src-tauri/Cargo.toml +1 -1
  36. package/src-tauri/tauri.conf.json +1 -1
  37. package/static/app/asset-manifest.json +5 -5
  38. package/static/app/assets/index-D2zafMYb.js +16 -0
  39. package/static/app/assets/index-D2zafMYb.js.map +1 -0
  40. package/static/app/assets/index-xRn29gI8.css +2 -0
  41. package/static/app/index.html +2 -2
  42. package/static/app/assets/index-C7vzwUjU.js +0 -16
  43. package/static/app/assets/index-C7vzwUjU.js.map +0 -1
  44. package/static/app/assets/index-HN4f2wbe.css +0 -2
@@ -53,10 +53,10 @@ export function FirstRunGuide() {
53
53
  <section className="arrival-panel" aria-label="First 10 minutes">
54
54
  <div className="arrival-copy">
55
55
  <div className="page-kicker"><CheckCircle2 className="h-4 w-4" /> First 10 minutes</div>
56
- <h2>Build your living Brain without guessing.</h2>
56
+ <h2>Start locally, with clear consent at each step.</h2>
57
57
  <p>
58
- Start with a local Brain, let Lattice recommend a model voice, then add the first pieces of durable knowledge.
59
- Every step keeps the next action visible.
58
+ Create the local Brain first, choose when to download a model, then add durable knowledge when you are ready.
59
+ Nothing needs cloud access unless you explicitly choose it.
60
60
  </p>
61
61
  <div className="arrival-actions">
62
62
  <Button onClick={() => go(nextStep.action)}>{nextStep.done ? "Open relationships" : `Continue: ${nextStep.label}`}</Button>
@@ -0,0 +1,91 @@
1
+ import { RotateCcw } from "lucide-react";
2
+ import type { ApiResult, ReviewItem } from "@/api/client";
3
+ import { ActionButton, KeyValueList } from "@/components/primitives";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { useAppStore } from "@/store/appStore";
7
+ import {
8
+ formatSnoozedUntil,
9
+ hasRunBefore,
10
+ isActionableReview,
11
+ reviewSourceDetail,
12
+ reviewSourceLabel,
13
+ reviewStatusVariant,
14
+ type ReviewAction,
15
+ } from "./reviewHelpers";
16
+
17
+ type ReviewCardProps = {
18
+ item: ReviewItem;
19
+ feedback?: string;
20
+ onAction: (item: ReviewItem, action: ReviewAction, hadRunBefore?: boolean) => Promise<ApiResult<ReviewItem>>;
21
+ };
22
+
23
+ export function ReviewCard({ item, feedback, onAction }: ReviewCardProps) {
24
+ const mode = useAppStore((state) => state.mode);
25
+ const provenance = item.provenance || {};
26
+ const payload = item.payload || {};
27
+ const hadRun = hasRunBefore(item);
28
+ const snoozed = item.effective_status === "snoozed";
29
+ const actionable = isActionableReview(item);
30
+
31
+ return (
32
+ <div className="rounded-lg border border-border bg-background/55 p-4">
33
+ <div className="flex flex-wrap items-start justify-between gap-3">
34
+ <div className="min-w-0 flex-1">
35
+ <div className="font-medium">{item.title}</div>
36
+ {item.summary ? <p className="mt-1 text-sm leading-6 text-muted-foreground">{item.summary}</p> : null}
37
+ </div>
38
+ <div className="flex flex-wrap items-center gap-2">
39
+ <Badge variant="muted">{reviewSourceLabel(item.source)}</Badge>
40
+ <Badge variant={reviewStatusVariant(item.effective_status)}>{item.effective_status}</Badge>
41
+ </div>
42
+ </div>
43
+
44
+ {snoozed ? (
45
+ <div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-muted/24 p-3 text-sm">
46
+ <div>
47
+ <div className="font-medium">{formatSnoozedUntil(item.snoozed_until)}</div>
48
+ <p className="mt-1 text-muted-foreground">This stays out of the pending queue until then. Unsnooze brings it back immediately.</p>
49
+ </div>
50
+ <Button size="sm" variant="outline" onClick={() => onAction(item, "unsnooze")} disabled={!actionable}>
51
+ <RotateCcw className="h-3.5 w-3.5" /> Unsnooze
52
+ </Button>
53
+ </div>
54
+ ) : null}
55
+
56
+ {mode !== "basic" ? (
57
+ <div className="mt-3">
58
+ <KeyValueList
59
+ data={{
60
+ workflow: provenance.workflow_id,
61
+ trigger: provenance.trigger_id,
62
+ run: payload.last_run_id || provenance.run_id,
63
+ source_detail: reviewSourceDetail(provenance, item.source),
64
+ snoozed_until: item.snoozed_until,
65
+ created_at: item.created_at,
66
+ updated_at: item.updated_at,
67
+ }}
68
+ limit={8}
69
+ />
70
+ </div>
71
+ ) : null}
72
+
73
+ {actionable ? (
74
+ <div className="mt-4 flex flex-wrap gap-2">
75
+ <ActionButton
76
+ label="Run now"
77
+ successLabel={hadRun ? "Regenerated" : "Executed"}
78
+ action={() => onAction(item, "run_now", hadRun)}
79
+ invalidate={[]}
80
+ />
81
+ <ActionButton label="Approve" action={() => onAction(item, "approve")} invalidate={[]} />
82
+ {!snoozed ? <ActionButton label="Snooze 1 day" action={() => onAction(item, "snooze")} invalidate={[]} /> : null}
83
+ <ActionButton label="Dismiss" action={() => onAction(item, "dismiss")} invalidate={[]} variant="destructive" />
84
+ </div>
85
+ ) : null}
86
+ {feedback ? (
87
+ <p className="mt-2 text-xs text-emerald-300">{feedback} - item stays open until you approve or dismiss.</p>
88
+ ) : null}
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,112 @@
1
+ import * as React from "react";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { latticeApi, type ReviewItem, type ReviewSourceFilter, type ReviewStatusFilter } from "@/api/client";
4
+ import { EmptyState, LoadingPanel, Tabs } from "@/components/primitives";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { ReviewCard } from "./ReviewCard";
8
+ import {
9
+ defaultSnoozeUntil,
10
+ reviewSourceFilters,
11
+ reviewStatusFilters,
12
+ type ReviewAction,
13
+ } from "./reviewHelpers";
14
+
15
+ export function ReviewInbox() {
16
+ const qc = useQueryClient();
17
+ const [statusFilter, setStatusFilter] = React.useState<ReviewStatusFilter>("pending");
18
+ const [sourceFilter, setSourceFilter] = React.useState<ReviewSourceFilter>("all");
19
+ const [runFeedback, setRunFeedback] = React.useState<Record<string, string>>({});
20
+ const reviews = useQuery({
21
+ queryKey: ["automationReviews", statusFilter, sourceFilter],
22
+ queryFn: () => latticeApi.automationReviews({
23
+ ...(statusFilter !== "all" ? { status: statusFilter } : {}),
24
+ ...(sourceFilter !== "all" ? { source: sourceFilter } : {}),
25
+ }),
26
+ });
27
+ const items = reviews.data?.data.items || [];
28
+
29
+ const actOnReview = async (
30
+ item: ReviewItem,
31
+ action: ReviewAction,
32
+ hadRunBefore = false,
33
+ ) => {
34
+ const call =
35
+ action === "approve" ? () => latticeApi.approveReviewItem(item.id) :
36
+ action === "dismiss" ? () => latticeApi.dismissReviewItem(item.id) :
37
+ action === "snooze" ? () => latticeApi.snoozeReviewItem(item.id, defaultSnoozeUntil()) :
38
+ action === "unsnooze" ? () => latticeApi.unsnoozeReviewItem(item.id) :
39
+ () => latticeApi.runNowReviewItem(item.id);
40
+ const result = await call();
41
+ if (result.ok) {
42
+ if (action === "run_now") {
43
+ setRunFeedback((prev) => ({
44
+ ...prev,
45
+ [item.id]: hadRunBefore ? "Regenerated" : "Executed",
46
+ }));
47
+ } else {
48
+ setRunFeedback((prev) => {
49
+ const next = { ...prev };
50
+ delete next[item.id];
51
+ return next;
52
+ });
53
+ }
54
+ await qc.invalidateQueries({ queryKey: ["automationReviews"] });
55
+ }
56
+ return result;
57
+ };
58
+
59
+ if (reviews.isLoading) return <LoadingPanel title="Review inbox" />;
60
+
61
+ return (
62
+ <Card>
63
+ <CardHeader className="gap-3">
64
+ <div className="flex flex-wrap items-start justify-between gap-3">
65
+ <div>
66
+ <CardTitle>Review inbox</CardTitle>
67
+ <CardDescription>Automation suggestions waiting for your decision. Run now executes without approving.</CardDescription>
68
+ </div>
69
+ {reviews.data ? (
70
+ <Badge variant={reviews.data.ok ? "success" : "warning"}>{reviews.data.ok ? "connected" : "unavailable"}</Badge>
71
+ ) : null}
72
+ </div>
73
+ <div className="grid gap-2">
74
+ <Tabs
75
+ tabs={reviewStatusFilters}
76
+ value={statusFilter}
77
+ onChange={(id) => setStatusFilter(id as ReviewStatusFilter)}
78
+ />
79
+ <Tabs
80
+ tabs={reviewSourceFilters}
81
+ value={sourceFilter}
82
+ onChange={(id) => setSourceFilter(id as ReviewSourceFilter)}
83
+ />
84
+ </div>
85
+ </CardHeader>
86
+ <CardContent>
87
+ {reviews.isError || (reviews.data && !reviews.data.ok) ? (
88
+ <EmptyState
89
+ title="Could not load review inbox"
90
+ detail={reviews.data?.error || "The review queue is not available right now."}
91
+ />
92
+ ) : !items.length ? (
93
+ <EmptyState
94
+ title="Nothing to review"
95
+ detail={statusFilter === "snoozed" ? "Snoozed items will appear here until they are unsnoozed or become pending again." : "When automations opt into the review queue, new suggestions will appear here."}
96
+ />
97
+ ) : (
98
+ <div className="grid gap-3">
99
+ {items.map((item) => (
100
+ <ReviewCard
101
+ key={item.id}
102
+ item={item}
103
+ feedback={runFeedback[item.id]}
104
+ onAction={actOnReview}
105
+ />
106
+ ))}
107
+ </div>
108
+ )}
109
+ </CardContent>
110
+ </Card>
111
+ );
112
+ }
@@ -0,0 +1,69 @@
1
+ import type * as React from "react";
2
+ import type { ReviewItem, ReviewSourceFilter, ReviewStatusFilter } from "@/api/client";
3
+ import type { Badge } from "@/components/ui/badge";
4
+
5
+ export type ReviewAction = "approve" | "dismiss" | "snooze" | "unsnooze" | "run_now";
6
+
7
+ export const reviewStatusFilters: Array<{ id: ReviewStatusFilter; label: string }> = [
8
+ { id: "pending", label: "Pending" },
9
+ { id: "snoozed", label: "Snoozed" },
10
+ { id: "all", label: "All" },
11
+ { id: "approved", label: "Approved" },
12
+ { id: "dismissed", label: "Dismissed" },
13
+ ];
14
+
15
+ export const reviewSourceFilters: Array<{ id: ReviewSourceFilter; label: string }> = [
16
+ { id: "all", label: "All sources" },
17
+ { id: "workflow_run", label: "Workflow" },
18
+ { id: "trigger", label: "Trigger" },
19
+ { id: "kg_change_digest", label: "KG digest" },
20
+ ];
21
+
22
+ export function reviewStatusVariant(status: string): React.ComponentProps<typeof Badge>["variant"] {
23
+ if (status === "pending") return "warning";
24
+ if (status === "snoozed") return "muted";
25
+ if (status === "approved") return "success";
26
+ if (status === "dismissed") return "danger";
27
+ return "muted";
28
+ }
29
+
30
+ export function reviewSourceLabel(source?: string) {
31
+ if (source === "workflow_run") return "Workflow run";
32
+ if (source === "trigger") return "Trigger";
33
+ if (source === "kg_change_digest") return "KG digest";
34
+ return source || "Automation";
35
+ }
36
+
37
+ export function reviewSourceDetail(provenance: Record<string, unknown>, source?: string) {
38
+ const detail = provenance.source_detail;
39
+ if (detail != null && String(detail).trim()) return String(detail);
40
+ const triggerId = provenance.trigger_id;
41
+ if (triggerId != null && String(triggerId).trim()) return String(triggerId);
42
+ return reviewSourceLabel(source);
43
+ }
44
+
45
+ export function defaultSnoozeUntil() {
46
+ const until = new Date();
47
+ until.setDate(until.getDate() + 1);
48
+ return until.toISOString();
49
+ }
50
+
51
+ export function formatSnoozedUntil(value?: string | null) {
52
+ if (!value) return "Snoozed";
53
+ const date = new Date(value);
54
+ if (Number.isNaN(date.getTime())) return `Snoozed until ${value}`;
55
+ return `Snoozed until ${new Intl.DateTimeFormat(undefined, {
56
+ dateStyle: "medium",
57
+ timeStyle: "short",
58
+ }).format(date)}`;
59
+ }
60
+
61
+ export function isActionableReview(item: ReviewItem) {
62
+ return item.effective_status === "pending" || item.effective_status === "snoozed";
63
+ }
64
+
65
+ export function hasRunBefore(item: ReviewItem) {
66
+ const payload = item.payload || {};
67
+ const provenance = item.provenance || {};
68
+ return Boolean(payload.last_run_id || provenance.run_id);
69
+ }
@@ -77,7 +77,7 @@ export const COPY: Record<Language, TextMap> = {
77
77
  "admin.body": "사용자, 로그, 보안, Brain 상태는 일반 사용자 화면과 분리됩니다.",
78
78
  "flow.shell": "내 로컬 AI 브레인 만들기",
79
79
  "flow.login.title": "내 AI 브레인의 주인을 정합니다.",
80
- "flow.login.body": "Lattice AI는 모델이 바뀌어도 내 지식과 맥락을 보존하는 로컬 우선 AI 브레인입니다. 대화와 기억은 기본적으로 컴퓨터에 저장됩니다.",
80
+ "flow.login.body": "Lattice AI는 내 지식과 맥락을 컴퓨터에 보관하는 로컬 우선 AI 브레인입니다. 모델은 바꿀 있고, 외부 전송은 사용자가 선택할 때만 시작됩니다.",
81
81
  "flow.name": "이름",
82
82
  "flow.email": "이메일",
83
83
  "flow.password": "비밀번호",
@@ -94,9 +94,9 @@ export const COPY: Record<Language, TextMap> = {
94
94
  "flow.promise.model.k": "교체 가능한 모델",
95
95
  "flow.promise.model.v": "모델은 목소리이고, 자산은 Brain입니다.",
96
96
  "flow.promise.ownership.k": "사용자 소유",
97
- "flow.promise.ownership.v": "백업, 복원, 이동이 가능합니다.",
97
+ "flow.promise.ownership.v": "백업, 복원, 이동을 직접 결정합니다.",
98
98
  "flow.analysis.title": "이 컴퓨터에서 가능한 경험을 확인합니다.",
99
- "flow.analysis.body": "스펙 점수가 아니라, 이 Mac에서 어떤 로컬 AI 브레인 경험이 편한지 알려드립니다. 클라우드 모델은 선택 사항입니다.",
99
+ "flow.analysis.body": "스펙 점수가 아니라, 이 Mac에서 어떤 로컬 AI 브레인 경험이 편한지 알려드립니다. 확인은 로컬 상태를 읽는 것이고, 클라우드 모델은 선택 사항입니다.",
100
100
  "flow.analysis.finding": "가장 편한 설정을 찾는 중...",
101
101
  "flow.analysis.ready": "추천 모델을 바로 시작할 수 있게 준비했습니다.",
102
102
  "flow.analysis.wait": "잠시만 기다리면 자동으로 정리됩니다.",
@@ -109,7 +109,7 @@ export const COPY: Record<Language, TextMap> = {
109
109
  "flow.recommend.back": "뒤로",
110
110
  "flow.recommend.hint": "잘 모르겠다면 추천대로 시작하면 됩니다.",
111
111
  "flow.install.title": "모델을 설치하고 시작합니다.",
112
- "flow.install.body": "이 모델이 Brain의 로컬 목소리가 됩니다. 다운로드할 때만 인터넷이 필요하고, 실행은 컴퓨터에서 진행됩니다.",
112
+ "flow.install.body": "이 모델이 Brain의 로컬 목소리가 됩니다. 인터넷은 다운로드할 때만 필요하고, 실행과 기억은 컴퓨터에서 유지됩니다.",
113
113
  "flow.install.wait": "Brain이 사용할 모델을 기다리고 있습니다.",
114
114
  "flow.install.prepare": "Brain 준비 중입니다.",
115
115
  "flow.install.done": "Brain이 준비되었습니다.",
@@ -190,7 +190,7 @@ export const COPY: Record<Language, TextMap> = {
190
190
  "admin.body": "Users, logs, security, and Brain health stay out of the normal user experience.",
191
191
  "flow.shell": "Create your local AI Brain",
192
192
  "flow.login.title": "Choose the owner of your AI Brain.",
193
- "flow.login.body": "Lattice AI is a local-first Digital Brain that keeps your knowledge durable across any AI model. Conversations and memories are stored on this computer by default.",
193
+ "flow.login.body": "Lattice AI is a local-first Digital Brain that keeps your knowledge on this computer. Models can change; external transfer starts only when you choose it.",
194
194
  "flow.name": "Name",
195
195
  "flow.email": "Email",
196
196
  "flow.password": "Password",
@@ -207,9 +207,9 @@ export const COPY: Record<Language, TextMap> = {
207
207
  "flow.promise.model.k": "Replaceable models",
208
208
  "flow.promise.model.v": "The model is the voice; the Brain is the asset.",
209
209
  "flow.promise.ownership.k": "User owned",
210
- "flow.promise.ownership.v": "Back up, restore, and move it.",
210
+ "flow.promise.ownership.v": "You decide when to back up, restore, or move it.",
211
211
  "flow.analysis.title": "Checking what this computer can do.",
212
- "flow.analysis.body": "This is not a spec test. Lattice explains what local AI Brain experience this Mac can support. Cloud models remain optional.",
212
+ "flow.analysis.body": "This is not a spec test. Lattice reads local capability and explains what AI Brain experience this Mac can support. Cloud models remain optional.",
213
213
  "flow.analysis.finding": "Finding the easiest setup...",
214
214
  "flow.analysis.ready": "Your recommended model is ready to start.",
215
215
  "flow.analysis.wait": "This will be summarized automatically in a moment.",
@@ -222,7 +222,7 @@ export const COPY: Record<Language, TextMap> = {
222
222
  "flow.recommend.back": "Back",
223
223
  "flow.recommend.hint": "If unsure, start with the recommendation.",
224
224
  "flow.install.title": "Install the model and start.",
225
- "flow.install.body": "This model becomes your Brain's local voice. Internet is needed only for download; execution happens on this computer.",
225
+ "flow.install.body": "This model becomes your Brain's local voice. Internet is needed only for download; execution and memory stay on this computer.",
226
226
  "flow.install.wait": "Waiting for the model your Brain will use.",
227
227
  "flow.install.prepare": "Preparing your Brain.",
228
228
  "flow.install.done": "Your Brain is ready.",
@@ -9,10 +9,17 @@ import { Button } from "@/components/ui/button";
9
9
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
10
10
  import { Input } from "@/components/ui/input";
11
11
  import { Textarea } from "@/components/ui/textarea";
12
+ import { ReviewInbox } from "@/features/review/ReviewInbox";
12
13
  import { useAppStore } from "@/store/appStore";
13
14
  import { asArray, shortId } from "@/lib/utils";
14
15
 
15
16
  type ActTab = "agents" | "runs" | "workflows" | "hooks" | "tools";
17
+ type RunsSubTab = "runs" | "review";
18
+
19
+ const runsSubTabs: Array<{ id: RunsSubTab; label: string }> = [
20
+ { id: "runs", label: "Runs" },
21
+ { id: "review", label: "Review Center" },
22
+ ];
16
23
 
17
24
  const tabs: Array<{ id: ActTab; label: string }> = [
18
25
  { id: "agents", label: "Goals" },
@@ -24,8 +31,17 @@ const tabs: Array<{ id: ActTab; label: string }> = [
24
31
 
25
32
  export function ActPage({ initialTab }: { initialTab?: string }) {
26
33
  const mode = useAppStore((state) => state.mode);
27
- const [tab, setTab] = React.useState<ActTab>((initialTab as ActTab) || "agents");
34
+ const [tab, setTab] = React.useState<ActTab>(() => {
35
+ if (initialTab === "review") return "runs";
36
+ return (initialTab as ActTab) || "agents";
37
+ });
38
+ const [runsSubTab, setRunsSubTab] = React.useState<RunsSubTab>(initialTab === "review" ? "review" : "runs");
28
39
  React.useEffect(() => {
40
+ if (initialTab === "review") {
41
+ setTab("runs");
42
+ setRunsSubTab("review");
43
+ return;
44
+ }
29
45
  if (tabs.some((item) => item.id === initialTab)) setTab(initialTab as ActTab);
30
46
  }, [initialTab]);
31
47
  return (
@@ -37,7 +53,7 @@ export function ActPage({ initialTab }: { initialTab?: string }) {
37
53
  </header>
38
54
  <Tabs tabs={tabs.map((item) => mode === "basic" ? item : item.id === "hooks" ? { ...item, label: "Hooks" } : item.id === "tools" ? { ...item, label: "Tools" } : item)} value={tab} onChange={(id) => setTab(id as ActTab)} />
39
55
  {tab === "agents" ? <AgentsPanel /> : null}
40
- {tab === "runs" ? <RunsPanel /> : null}
56
+ {tab === "runs" ? <RunsPanel subTab={runsSubTab} onSubTabChange={setRunsSubTab} /> : null}
41
57
  {tab === "workflows" ? <WorkflowsPanel /> : null}
42
58
  {tab === "hooks" ? <HooksPanel /> : null}
43
59
  {tab === "tools" ? <ToolsPanel /> : null}
@@ -123,7 +139,16 @@ function AgentsPanel() {
123
139
  );
124
140
  }
125
141
 
126
- function RunsPanel() {
142
+ function RunsPanel({ subTab, onSubTabChange }: { subTab: RunsSubTab; onSubTabChange: (tab: RunsSubTab) => void }) {
143
+ return (
144
+ <div className="space-y-4">
145
+ <Tabs tabs={runsSubTabs} value={subTab} onChange={(id) => onSubTabChange(id as RunsSubTab)} />
146
+ {subTab === "runs" ? <RunsListPanel /> : <ReviewInbox />}
147
+ </div>
148
+ );
149
+ }
150
+
151
+ function RunsListPanel() {
127
152
  const mode = useAppStore((state) => state.mode);
128
153
  const runtime = useQuery({ queryKey: ["agentRuntime"], queryFn: latticeApi.agentRuntime });
129
154
  const workflows = useQuery({ queryKey: ["workflowRuns"], queryFn: latticeApi.workflowRuns });
@@ -31,6 +31,8 @@ export const routeAliases: Record<string, { primary: PrimaryRoute; tab?: string
31
31
  "my-computer": { primary: "capture", tab: "local" },
32
32
  agents: { primary: "act", tab: "agents" },
33
33
  runs: { primary: "act", tab: "runs" },
34
+ review: { primary: "act", tab: "review" },
35
+ "review-center": { primary: "act", tab: "review" },
34
36
  workflows: { primary: "act", tab: "workflows" },
35
37
  planning: { primary: "act", tab: "agents" },
36
38
  hooks: { primary: "act", tab: "hooks" },
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "5.5.0"
29
+ __version__ = "6.0.0"
30
30
 
31
31
  __all__ = [
32
32
  "AgentRuntime",
@@ -19,7 +19,7 @@ from datetime import datetime
19
19
  from typing import Any, Callable, Dict, List, Optional
20
20
 
21
21
 
22
- MULTI_AGENT_VERSION = "5.5.0"
22
+ MULTI_AGENT_VERSION = "6.0.0"
23
23
 
24
24
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
25
25
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "5.5.0"
3
+ __version__ = "6.0.0"
@@ -0,0 +1,162 @@
1
+ """Brain Review Queue API router (5.6.0).
2
+
3
+ The suggestion inbox the /app Review view drives. Follows the existing
4
+ auth/workspace dependency pattern (``require_user`` + ``gate_read``/``gate_write``)
5
+ and exposes explicit response models so the OpenAPI types are usable from the
6
+ frontend without massaging.
7
+
8
+ Action semantics live in :class:`~latticeai.services.review_queue.ReviewQueueService`:
9
+
10
+ * ``approve`` / ``dismiss`` / ``snooze`` / ``unsnooze`` are status transitions;
11
+ an illegal transition returns **409**.
12
+ * ``run_now`` previews/regenerates without changing status (back-links the run).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Callable, Dict, List, Optional
18
+
19
+ from fastapi import APIRouter, HTTPException, Request
20
+ from pydantic import BaseModel, Field
21
+
22
+ from latticeai.services.review_queue import InvalidReviewTransition, ReviewQueueService
23
+
24
+
25
+ class ReviewItem(BaseModel):
26
+ id: str
27
+ status: str
28
+ effective_status: str
29
+ title: str
30
+ summary: str = ""
31
+ source: str = "workflow_run"
32
+ kind: str = "suggestion"
33
+ payload: Dict[str, Any] = Field(default_factory=dict)
34
+ provenance: Dict[str, Any] = Field(default_factory=dict)
35
+ snoozed_until: Optional[str] = None
36
+ user_email: Optional[str] = None
37
+ workspace_id: Optional[str] = None
38
+ created_at: Optional[str] = None
39
+ updated_at: Optional[str] = None
40
+
41
+
42
+ class ReviewItemList(BaseModel):
43
+ items: List[ReviewItem] = Field(default_factory=list)
44
+
45
+
46
+ class CreateReviewItemRequest(BaseModel):
47
+ title: str
48
+ summary: str = ""
49
+ source: str = "workflow_run"
50
+ kind: str = "suggestion"
51
+ payload: Dict[str, Any] = Field(default_factory=dict)
52
+ provenance: Dict[str, Any] = Field(default_factory=dict)
53
+
54
+
55
+ class SnoozeRequest(BaseModel):
56
+ until: str
57
+
58
+
59
+ def create_review_queue_router(
60
+ *,
61
+ service: ReviewQueueService,
62
+ require_user: Callable[[Request], str],
63
+ gate_read: Callable[[Request], Optional[str]],
64
+ gate_write: Callable[[Request], Optional[str]],
65
+ run_review_item: Callable[..., Any],
66
+ append_audit_event: Callable[..., None],
67
+ ) -> APIRouter:
68
+ router = APIRouter()
69
+
70
+ @router.get("/automation/reviews", response_model=ReviewItemList)
71
+ async def list_items(
72
+ request: Request, status: Optional[str] = None, source: Optional[str] = None,
73
+ ):
74
+ user = require_user(request)
75
+ scope = gate_read(request)
76
+ return service.list(workspace_id=scope, user_email=user, status=status, source=source)
77
+
78
+ @router.post("/automation/reviews", response_model=ReviewItem)
79
+ async def create_item(req: CreateReviewItemRequest, request: Request):
80
+ user = require_user(request)
81
+ scope = gate_write(request)
82
+ try:
83
+ item = service.create(
84
+ title=req.title,
85
+ summary=req.summary,
86
+ source=req.source,
87
+ kind=req.kind,
88
+ payload=req.payload,
89
+ provenance=req.provenance,
90
+ user_email=user,
91
+ workspace_id=scope,
92
+ )
93
+ except ValueError as exc:
94
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
95
+ append_audit_event("review_item_created", user_email=user, item_id=item["id"])
96
+ return item
97
+
98
+ @router.get("/automation/reviews/{item_id}", response_model=ReviewItem)
99
+ async def get_item(item_id: str, request: Request):
100
+ require_user(request)
101
+ scope = gate_read(request)
102
+ try:
103
+ return service.get(item_id, workspace_id=scope)
104
+ except FileNotFoundError as exc:
105
+ raise HTTPException(status_code=404, detail="review item not found") from exc
106
+
107
+ @router.post("/automation/reviews/{item_id}/approve", response_model=ReviewItem)
108
+ async def approve_item(item_id: str, request: Request):
109
+ return _act(request, item_id, "approve")
110
+
111
+ @router.post("/automation/reviews/{item_id}/dismiss", response_model=ReviewItem)
112
+ async def dismiss_item(item_id: str, request: Request):
113
+ return _act(request, item_id, "dismiss")
114
+
115
+ @router.post("/automation/reviews/{item_id}/snooze", response_model=ReviewItem)
116
+ async def snooze_item(item_id: str, req: SnoozeRequest, request: Request):
117
+ user = require_user(request)
118
+ scope = gate_write(request)
119
+ try:
120
+ item = service.snooze(item_id, until=req.until, workspace_id=scope)
121
+ except FileNotFoundError as exc:
122
+ raise HTTPException(status_code=404, detail="review item not found") from exc
123
+ except InvalidReviewTransition as exc:
124
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
125
+ append_audit_event("review_item_snooze", user_email=user, item_id=item_id)
126
+ return item
127
+
128
+ @router.post("/automation/reviews/{item_id}/unsnooze", response_model=ReviewItem)
129
+ async def unsnooze_item(item_id: str, request: Request):
130
+ return _act(request, item_id, "unsnooze")
131
+
132
+ @router.post("/automation/reviews/{item_id}/run_now", response_model=ReviewItem)
133
+ async def run_now_item(item_id: str, request: Request):
134
+ user = require_user(request)
135
+ scope = gate_write(request)
136
+ try:
137
+ item = service.run_now(
138
+ item_id,
139
+ runner=lambda stored: run_review_item(stored, user_email=user, scope=scope),
140
+ workspace_id=scope,
141
+ )
142
+ except FileNotFoundError as exc:
143
+ raise HTTPException(status_code=404, detail="review item not found") from exc
144
+ except InvalidReviewTransition as exc:
145
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
146
+ append_audit_event("review_item_run_now", user_email=user, item_id=item_id)
147
+ return item
148
+
149
+ def _act(request: Request, item_id: str, action: str) -> Dict[str, Any]:
150
+ user = require_user(request)
151
+ scope = gate_write(request)
152
+ fn = getattr(service, action)
153
+ try:
154
+ item = fn(item_id, workspace_id=scope)
155
+ except FileNotFoundError as exc:
156
+ raise HTTPException(status_code=404, detail="review item not found") from exc
157
+ except InvalidReviewTransition as exc:
158
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
159
+ append_audit_event(f"review_item_{action}", user_email=user, item_id=item_id)
160
+ return item
161
+
162
+ return router