ltcai 5.4.0 → 5.6.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 +17 -15
- package/docs/CHANGELOG.md +50 -0
- package/frontend/openapi.json +677 -3
- package/frontend/src/api/client.ts +36 -0
- package/frontend/src/api/openapi.ts +573 -4
- package/frontend/src/pages/Act.tsx +202 -5
- package/frontend/src/routes.ts +1 -0
- 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/review_queue.py +158 -0
- package/latticeai/app_factory.py +28 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +86 -1
- package/latticeai/services/review_queue.py +255 -0
- package/latticeai/services/run_executor.py +33 -0
- package/latticeai/services/triggers.py +30 -1
- package/package.json +1 -1
- 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 +5 -5
- package/static/app/assets/index-xMFu94cX.js +16 -0
- package/static/app/assets/index-xMFu94cX.js.map +1 -0
- package/static/app/assets/index-xRn29gI8.css +2 -0
- package/static/app/index.html +2 -2
- package/static/app/assets/index-C7vzwUjU.js +0 -16
- package/static/app/assets/index-C7vzwUjU.js.map +0 -1
- package/static/app/assets/index-HN4f2wbe.css +0 -2
|
@@ -2,8 +2,8 @@ import * as React from "react";
|
|
|
2
2
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
3
|
import ReactFlow, { Background, Controls, Edge, Node } from "reactflow";
|
|
4
4
|
import { Bot, CalendarClock, GitBranch, PauseCircle, Play, ShieldCheck, Workflow } from "lucide-react";
|
|
5
|
-
import { latticeApi } from "@/api/client";
|
|
6
|
-
import { ActionButton, DataPanel, EntityList, KeyValueList, ModeGate, OperationResult, StructuredView, Tabs } from "@/components/primitives";
|
|
5
|
+
import { latticeApi, type ReviewItem } from "@/api/client";
|
|
6
|
+
import { ActionButton, DataPanel, EmptyState, EntityList, KeyValueList, LoadingPanel, ModeGate, OperationResult, StructuredView, Tabs } from "@/components/primitives";
|
|
7
7
|
import { Badge } from "@/components/ui/badge";
|
|
8
8
|
import { Button } from "@/components/ui/button";
|
|
9
9
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
@@ -13,6 +13,20 @@ import { useAppStore } from "@/store/appStore";
|
|
|
13
13
|
import { asArray, shortId } from "@/lib/utils";
|
|
14
14
|
|
|
15
15
|
type ActTab = "agents" | "runs" | "workflows" | "hooks" | "tools";
|
|
16
|
+
type RunsSubTab = "runs" | "review";
|
|
17
|
+
type ReviewSourceFilter = "all" | "workflow_run" | "trigger" | "kg_change_digest";
|
|
18
|
+
|
|
19
|
+
const runsSubTabs: Array<{ id: RunsSubTab; label: string }> = [
|
|
20
|
+
{ id: "runs", label: "Runs" },
|
|
21
|
+
{ id: "review", label: "Review" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const reviewSourceFilters: Array<{ id: ReviewSourceFilter; label: string }> = [
|
|
25
|
+
{ id: "all", label: "All" },
|
|
26
|
+
{ id: "workflow_run", label: "Workflow" },
|
|
27
|
+
{ id: "trigger", label: "Trigger" },
|
|
28
|
+
{ id: "kg_change_digest", label: "KG digest" },
|
|
29
|
+
];
|
|
16
30
|
|
|
17
31
|
const tabs: Array<{ id: ActTab; label: string }> = [
|
|
18
32
|
{ id: "agents", label: "Goals" },
|
|
@@ -24,8 +38,17 @@ const tabs: Array<{ id: ActTab; label: string }> = [
|
|
|
24
38
|
|
|
25
39
|
export function ActPage({ initialTab }: { initialTab?: string }) {
|
|
26
40
|
const mode = useAppStore((state) => state.mode);
|
|
27
|
-
const [tab, setTab] = React.useState<ActTab>((
|
|
41
|
+
const [tab, setTab] = React.useState<ActTab>(() => {
|
|
42
|
+
if (initialTab === "review") return "runs";
|
|
43
|
+
return (initialTab as ActTab) || "agents";
|
|
44
|
+
});
|
|
45
|
+
const [runsSubTab, setRunsSubTab] = React.useState<RunsSubTab>(initialTab === "review" ? "review" : "runs");
|
|
28
46
|
React.useEffect(() => {
|
|
47
|
+
if (initialTab === "review") {
|
|
48
|
+
setTab("runs");
|
|
49
|
+
setRunsSubTab("review");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
29
52
|
if (tabs.some((item) => item.id === initialTab)) setTab(initialTab as ActTab);
|
|
30
53
|
}, [initialTab]);
|
|
31
54
|
return (
|
|
@@ -37,7 +60,7 @@ export function ActPage({ initialTab }: { initialTab?: string }) {
|
|
|
37
60
|
</header>
|
|
38
61
|
<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
62
|
{tab === "agents" ? <AgentsPanel /> : null}
|
|
40
|
-
{tab === "runs" ? <RunsPanel /> : null}
|
|
63
|
+
{tab === "runs" ? <RunsPanel subTab={runsSubTab} onSubTabChange={setRunsSubTab} /> : null}
|
|
41
64
|
{tab === "workflows" ? <WorkflowsPanel /> : null}
|
|
42
65
|
{tab === "hooks" ? <HooksPanel /> : null}
|
|
43
66
|
{tab === "tools" ? <ToolsPanel /> : null}
|
|
@@ -123,7 +146,16 @@ function AgentsPanel() {
|
|
|
123
146
|
);
|
|
124
147
|
}
|
|
125
148
|
|
|
126
|
-
function RunsPanel() {
|
|
149
|
+
function RunsPanel({ subTab, onSubTabChange }: { subTab: RunsSubTab; onSubTabChange: (tab: RunsSubTab) => void }) {
|
|
150
|
+
return (
|
|
151
|
+
<div className="space-y-4">
|
|
152
|
+
<Tabs tabs={runsSubTabs} value={subTab} onChange={(id) => onSubTabChange(id as RunsSubTab)} />
|
|
153
|
+
{subTab === "runs" ? <RunsListPanel /> : <ReviewInboxPanel />}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function RunsListPanel() {
|
|
127
159
|
const mode = useAppStore((state) => state.mode);
|
|
128
160
|
const runtime = useQuery({ queryKey: ["agentRuntime"], queryFn: latticeApi.agentRuntime });
|
|
129
161
|
const workflows = useQuery({ queryKey: ["workflowRuns"], queryFn: latticeApi.workflowRuns });
|
|
@@ -166,6 +198,171 @@ function RunsPanel() {
|
|
|
166
198
|
);
|
|
167
199
|
}
|
|
168
200
|
|
|
201
|
+
function reviewStatusVariant(status: string): React.ComponentProps<typeof Badge>["variant"] {
|
|
202
|
+
if (status === "pending") return "warning";
|
|
203
|
+
if (status === "snoozed") return "muted";
|
|
204
|
+
if (status === "approved") return "success";
|
|
205
|
+
if (status === "dismissed") return "danger";
|
|
206
|
+
return "muted";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function reviewSourceLabel(source?: string) {
|
|
210
|
+
if (source === "workflow_run") return "Workflow run";
|
|
211
|
+
if (source === "trigger") return "Trigger";
|
|
212
|
+
if (source === "kg_change_digest") return "KG digest";
|
|
213
|
+
return source || "Automation";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function reviewSourceDetail(provenance: Record<string, unknown>, source?: string) {
|
|
217
|
+
const detail = provenance.source_detail;
|
|
218
|
+
if (detail != null && String(detail).trim()) return String(detail);
|
|
219
|
+
const triggerId = provenance.trigger_id;
|
|
220
|
+
if (triggerId != null && String(triggerId).trim()) return String(triggerId);
|
|
221
|
+
return reviewSourceLabel(source);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function defaultSnoozeUntil() {
|
|
225
|
+
const until = new Date();
|
|
226
|
+
until.setDate(until.getDate() + 1);
|
|
227
|
+
return until.toISOString();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function ReviewInboxPanel() {
|
|
231
|
+
const mode = useAppStore((state) => state.mode);
|
|
232
|
+
const qc = useQueryClient();
|
|
233
|
+
const [sourceFilter, setSourceFilter] = React.useState<ReviewSourceFilter>("all");
|
|
234
|
+
const [runFeedback, setRunFeedback] = React.useState<Record<string, string>>({});
|
|
235
|
+
const reviews = useQuery({
|
|
236
|
+
queryKey: ["automationReviews", sourceFilter],
|
|
237
|
+
queryFn: () => latticeApi.automationReviews({
|
|
238
|
+
status: "pending",
|
|
239
|
+
...(sourceFilter !== "all" ? { source: sourceFilter } : {}),
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
const items = asArray<ReviewItem>((reviews.data?.data as { items?: ReviewItem[] })?.items);
|
|
243
|
+
const actionable = (item: ReviewItem) => item.effective_status === "pending" || item.effective_status === "snoozed";
|
|
244
|
+
|
|
245
|
+
const actOnReview = async (
|
|
246
|
+
item: ReviewItem,
|
|
247
|
+
action: "approve" | "dismiss" | "snooze" | "run_now",
|
|
248
|
+
hadRunBefore = false,
|
|
249
|
+
) => {
|
|
250
|
+
const call =
|
|
251
|
+
action === "approve" ? () => latticeApi.approveReviewItem(item.id) :
|
|
252
|
+
action === "dismiss" ? () => latticeApi.dismissReviewItem(item.id) :
|
|
253
|
+
action === "snooze" ? () => latticeApi.snoozeReviewItem(item.id, defaultSnoozeUntil()) :
|
|
254
|
+
() => latticeApi.runNowReviewItem(item.id);
|
|
255
|
+
const result = await call();
|
|
256
|
+
if (result.ok) {
|
|
257
|
+
if (action === "run_now") {
|
|
258
|
+
setRunFeedback((prev) => ({
|
|
259
|
+
...prev,
|
|
260
|
+
[item.id]: hadRunBefore ? "Regenerated" : "Executed",
|
|
261
|
+
}));
|
|
262
|
+
} else {
|
|
263
|
+
setRunFeedback((prev) => {
|
|
264
|
+
const next = { ...prev };
|
|
265
|
+
delete next[item.id];
|
|
266
|
+
return next;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
await qc.invalidateQueries({ queryKey: ["automationReviews"] });
|
|
270
|
+
}
|
|
271
|
+
return result;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
if (reviews.isLoading) return <LoadingPanel title="Review inbox" />;
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<Card>
|
|
278
|
+
<CardHeader className="gap-3">
|
|
279
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
280
|
+
<div>
|
|
281
|
+
<CardTitle>Review inbox</CardTitle>
|
|
282
|
+
<CardDescription>Automation suggestions waiting for your decision. Run now previews without approving.</CardDescription>
|
|
283
|
+
</div>
|
|
284
|
+
{reviews.data ? (
|
|
285
|
+
<Badge variant={reviews.data.ok ? "success" : "warning"}>{reviews.data.ok ? "connected" : "unavailable"}</Badge>
|
|
286
|
+
) : null}
|
|
287
|
+
</div>
|
|
288
|
+
<Tabs
|
|
289
|
+
tabs={reviewSourceFilters}
|
|
290
|
+
value={sourceFilter}
|
|
291
|
+
onChange={(id) => setSourceFilter(id as ReviewSourceFilter)}
|
|
292
|
+
/>
|
|
293
|
+
</CardHeader>
|
|
294
|
+
<CardContent>
|
|
295
|
+
{reviews.isError || (reviews.data && !reviews.data.ok) ? (
|
|
296
|
+
<EmptyState
|
|
297
|
+
title="Could not load review inbox"
|
|
298
|
+
detail={reviews.data?.error || "The review queue is not available right now."}
|
|
299
|
+
/>
|
|
300
|
+
) : !items.length ? (
|
|
301
|
+
<EmptyState
|
|
302
|
+
title="Nothing to review"
|
|
303
|
+
detail="When automations opt into the review queue, new suggestions will appear here."
|
|
304
|
+
/>
|
|
305
|
+
) : (
|
|
306
|
+
<div className="grid gap-3">
|
|
307
|
+
{items.map((item) => {
|
|
308
|
+
const provenance = (item.provenance || {}) as Record<string, unknown>;
|
|
309
|
+
const payload = (item.payload || {}) as Record<string, unknown>;
|
|
310
|
+
const hadRunBefore = Boolean(payload.last_run_id || provenance.run_id);
|
|
311
|
+
const feedback = runFeedback[item.id];
|
|
312
|
+
return (
|
|
313
|
+
<div key={item.id} className="rounded-lg border border-border bg-background/55 p-4">
|
|
314
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
315
|
+
<div className="min-w-0 flex-1">
|
|
316
|
+
<div className="font-medium">{item.title}</div>
|
|
317
|
+
{item.summary ? <p className="mt-1 text-sm text-muted-foreground">{item.summary}</p> : null}
|
|
318
|
+
</div>
|
|
319
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
320
|
+
<Badge variant="muted">{reviewSourceLabel(item.source)}</Badge>
|
|
321
|
+
<Badge variant={reviewStatusVariant(item.effective_status)}>{item.effective_status}</Badge>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
{mode !== "basic" ? (
|
|
325
|
+
<div className="mt-3">
|
|
326
|
+
<KeyValueList
|
|
327
|
+
data={{
|
|
328
|
+
workflow: provenance.workflow_id,
|
|
329
|
+
trigger: provenance.trigger_id,
|
|
330
|
+
run: payload.last_run_id || provenance.run_id,
|
|
331
|
+
source_detail: reviewSourceDetail(provenance, item.source),
|
|
332
|
+
snoozed_until: item.snoozed_until,
|
|
333
|
+
created_at: item.created_at,
|
|
334
|
+
updated_at: item.updated_at,
|
|
335
|
+
}}
|
|
336
|
+
limit={8}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
) : null}
|
|
340
|
+
{actionable(item) ? (
|
|
341
|
+
<div className="mt-4 flex flex-wrap gap-2">
|
|
342
|
+
<ActionButton
|
|
343
|
+
label="Run now"
|
|
344
|
+
successLabel={hadRunBefore ? "Regenerated" : "Executed"}
|
|
345
|
+
action={() => actOnReview(item, "run_now", hadRunBefore)}
|
|
346
|
+
invalidate={[]}
|
|
347
|
+
/>
|
|
348
|
+
<ActionButton label="Approve" action={() => actOnReview(item, "approve")} invalidate={[]} />
|
|
349
|
+
<ActionButton label="Snooze" action={() => actOnReview(item, "snooze")} invalidate={[]} />
|
|
350
|
+
<ActionButton label="Dismiss" action={() => actOnReview(item, "dismiss")} invalidate={[]} variant="destructive" />
|
|
351
|
+
</div>
|
|
352
|
+
) : null}
|
|
353
|
+
{feedback ? (
|
|
354
|
+
<p className="mt-2 text-xs text-emerald-300">{feedback} — item stays open until you approve or dismiss.</p>
|
|
355
|
+
) : null}
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
})}
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
</CardContent>
|
|
362
|
+
</Card>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
169
366
|
function RunList({ runs, kind }: { runs: Array<Record<string, unknown>>; kind: "agent" | "workflow" }) {
|
|
170
367
|
if (!runs.length) return <EntityList items={[]} />;
|
|
171
368
|
return (
|
package/frontend/src/routes.ts
CHANGED
|
@@ -31,6 +31,7 @@ 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" },
|
|
34
35
|
workflows: { primary: "act", tab: "workflows" },
|
|
35
36
|
planning: { primary: "act", tab: "agents" },
|
|
36
37
|
hooks: { primary: "act", tab: "hooks" },
|
|
@@ -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.
|
|
22
|
+
MULTI_AGENT_VERSION = "5.6.0"
|
|
23
23
|
|
|
24
24
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
25
25
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
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`` are status transitions; an illegal
|
|
11
|
+
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}/run_now", response_model=ReviewItem)
|
|
129
|
+
async def run_now_item(item_id: str, request: Request):
|
|
130
|
+
user = require_user(request)
|
|
131
|
+
scope = gate_write(request)
|
|
132
|
+
try:
|
|
133
|
+
item = service.run_now(
|
|
134
|
+
item_id,
|
|
135
|
+
runner=lambda stored: run_review_item(stored, user_email=user, scope=scope),
|
|
136
|
+
workspace_id=scope,
|
|
137
|
+
)
|
|
138
|
+
except FileNotFoundError as exc:
|
|
139
|
+
raise HTTPException(status_code=404, detail="review item not found") from exc
|
|
140
|
+
except InvalidReviewTransition as exc:
|
|
141
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
142
|
+
append_audit_event("review_item_run_now", user_email=user, item_id=item_id)
|
|
143
|
+
return item
|
|
144
|
+
|
|
145
|
+
def _act(request: Request, item_id: str, action: str) -> Dict[str, Any]:
|
|
146
|
+
user = require_user(request)
|
|
147
|
+
scope = gate_write(request)
|
|
148
|
+
fn = getattr(service, action)
|
|
149
|
+
try:
|
|
150
|
+
item = fn(item_id, workspace_id=scope)
|
|
151
|
+
except FileNotFoundError as exc:
|
|
152
|
+
raise HTTPException(status_code=404, detail="review item not found") from exc
|
|
153
|
+
except InvalidReviewTransition as exc:
|
|
154
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
155
|
+
append_audit_event(f"review_item_{action}", user_email=user, item_id=item_id)
|
|
156
|
+
return item
|
|
157
|
+
|
|
158
|
+
return router
|
package/latticeai/app_factory.py
CHANGED
|
@@ -1480,6 +1480,11 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1480
1480
|
agent_registry=AGENT_REGISTRY,
|
|
1481
1481
|
)
|
|
1482
1482
|
|
|
1483
|
+
# ── Brain review queue (5.6.0): the suggestion inbox automation drops into.
|
|
1484
|
+
from latticeai.services.review_queue import ReviewQueueService
|
|
1485
|
+
|
|
1486
|
+
REVIEW_QUEUE = ReviewQueueService(store=WORKSPACE_OS)
|
|
1487
|
+
|
|
1483
1488
|
# ── v4 Trigger system (T7d): interval + brain-event workflow triggers.
|
|
1484
1489
|
from latticeai.services.triggers import TRIGGER_HOOK_NAME, TriggerService
|
|
1485
1490
|
|
|
@@ -1489,6 +1494,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1489
1494
|
wf_id, None, None, with_agent=False, inputs=inputs,
|
|
1490
1495
|
),
|
|
1491
1496
|
data_dir=DATA_DIR,
|
|
1497
|
+
review_sink=REVIEW_QUEUE,
|
|
1492
1498
|
)
|
|
1493
1499
|
# Idempotent hook registration: ingestion post_tool events fan into triggers.
|
|
1494
1500
|
_trigger_hook_id = next(
|
|
@@ -1520,6 +1526,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1520
1526
|
workspace_graph=_workspace_graph,
|
|
1521
1527
|
append_audit_event=append_audit_event,
|
|
1522
1528
|
hooks=HOOKS_REGISTRY,
|
|
1529
|
+
review_sink=REVIEW_QUEUE,
|
|
1523
1530
|
)
|
|
1524
1531
|
AGENT_RUNTIME.attach_executor(RUN_EXECUTOR)
|
|
1525
1532
|
app.state.run_executor = RUN_EXECUTOR
|
|
@@ -1723,6 +1730,27 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1723
1730
|
append_audit_event=append_audit_event,
|
|
1724
1731
|
))
|
|
1725
1732
|
|
|
1733
|
+
from latticeai.api.review_queue import create_review_queue_router
|
|
1734
|
+
|
|
1735
|
+
def _run_review_item(item, *, user_email, scope):
|
|
1736
|
+
"""run_now: re-execute the suggestion's source workflow (preview/regenerate)."""
|
|
1737
|
+
wf_id = (item.get("payload") or {}).get("workflow_id") or (item.get("provenance") or {}).get("workflow_id")
|
|
1738
|
+
if not wf_id:
|
|
1739
|
+
raise HTTPException(status_code=409, detail="review item has no workflow to run")
|
|
1740
|
+
return PLATFORM.run_workflow_by_id(
|
|
1741
|
+
wf_id, user_email, scope, with_agent=False,
|
|
1742
|
+
inputs={"__review_item__": item.get("id")},
|
|
1743
|
+
)
|
|
1744
|
+
|
|
1745
|
+
app.include_router(create_review_queue_router(
|
|
1746
|
+
service=REVIEW_QUEUE,
|
|
1747
|
+
require_user=require_user,
|
|
1748
|
+
gate_read=PLATFORM.gate_read,
|
|
1749
|
+
gate_write=PLATFORM.gate_write,
|
|
1750
|
+
run_review_item=_run_review_item,
|
|
1751
|
+
append_audit_event=append_audit_event,
|
|
1752
|
+
))
|
|
1753
|
+
|
|
1726
1754
|
app.include_router(create_browser_router(
|
|
1727
1755
|
pipeline=INGESTION_PIPELINE,
|
|
1728
1756
|
require_user=require_user,
|
|
@@ -19,7 +19,7 @@ from pathlib import Path
|
|
|
19
19
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
WORKSPACE_OS_VERSION = "5.
|
|
22
|
+
WORKSPACE_OS_VERSION = "5.6.0"
|
|
23
23
|
|
|
24
24
|
# Workspace types separate single-user Personal workspaces from shared
|
|
25
25
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -480,6 +480,7 @@ class WorkspaceOSStore:
|
|
|
480
480
|
"handoffs": [],
|
|
481
481
|
"workflows": [],
|
|
482
482
|
"workflow_runs": [],
|
|
483
|
+
"review_items": [],
|
|
483
484
|
"skill_registry": {},
|
|
484
485
|
"plugin_registry": {},
|
|
485
486
|
"template_registry": {},
|
|
@@ -1881,6 +1882,90 @@ class WorkspaceOSStore:
|
|
|
1881
1882
|
raise FileNotFoundError(run_id)
|
|
1882
1883
|
return run
|
|
1883
1884
|
|
|
1885
|
+
# ── review queue (5.6.0) ─────────────────────────────────────────────
|
|
1886
|
+
# Workspace-scoped suggestion inbox. Automation/trigger runs write drafts
|
|
1887
|
+
# here for the user to approve/dismiss/snooze. Persistence only; the
|
|
1888
|
+
# transition policy lives in ReviewQueueService (services/review_queue.py).
|
|
1889
|
+
|
|
1890
|
+
def create_review_item(
|
|
1891
|
+
self,
|
|
1892
|
+
*,
|
|
1893
|
+
title: str,
|
|
1894
|
+
summary: str = "",
|
|
1895
|
+
source: str = "workflow_run",
|
|
1896
|
+
kind: str = "suggestion",
|
|
1897
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
1898
|
+
provenance: Optional[Dict[str, Any]] = None,
|
|
1899
|
+
user_email: Optional[str] = None,
|
|
1900
|
+
workspace_id: Optional[str] = None,
|
|
1901
|
+
) -> Dict[str, Any]:
|
|
1902
|
+
if not str(title or "").strip():
|
|
1903
|
+
raise ValueError("title is required")
|
|
1904
|
+
state = self.load_state()
|
|
1905
|
+
resolved_workspace = self._resolve_scope(workspace_id, state)
|
|
1906
|
+
now = _now()
|
|
1907
|
+
item = {
|
|
1908
|
+
"id": f"review-{_json_hash([title, source, kind, user_email, now])[:16]}",
|
|
1909
|
+
"status": "pending",
|
|
1910
|
+
"title": title,
|
|
1911
|
+
"summary": summary or "",
|
|
1912
|
+
"source": source or "workflow_run",
|
|
1913
|
+
"kind": kind or "suggestion",
|
|
1914
|
+
"payload": dict(payload or {}),
|
|
1915
|
+
"provenance": dict(provenance or {}),
|
|
1916
|
+
"snoozed_until": None,
|
|
1917
|
+
"user_email": user_email,
|
|
1918
|
+
"workspace_id": resolved_workspace,
|
|
1919
|
+
"created_at": now,
|
|
1920
|
+
"updated_at": now,
|
|
1921
|
+
}
|
|
1922
|
+
state.setdefault("review_items", []).append(item)
|
|
1923
|
+
self.save_state(state)
|
|
1924
|
+
self.record_timeline_event(
|
|
1925
|
+
"review", "review_item_created",
|
|
1926
|
+
{"item_id": item["id"], "source": item["source"], "kind": item["kind"]},
|
|
1927
|
+
workspace_id=resolved_workspace,
|
|
1928
|
+
)
|
|
1929
|
+
return item
|
|
1930
|
+
|
|
1931
|
+
def list_review_items(
|
|
1932
|
+
self, *, workspace_id: Optional[str] = None, user_email: Optional[str] = None,
|
|
1933
|
+
source: Optional[str] = None,
|
|
1934
|
+
) -> List[Dict[str, Any]]:
|
|
1935
|
+
items = self._scoped(_listify(self.load_state().get("review_items")), workspace_id)
|
|
1936
|
+
if user_email:
|
|
1937
|
+
items = [item for item in items if item.get("user_email") in {None, user_email}]
|
|
1938
|
+
if source:
|
|
1939
|
+
items = [item for item in items if item.get("source") == source]
|
|
1940
|
+
return list(reversed(items))
|
|
1941
|
+
|
|
1942
|
+
def get_review_item(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1943
|
+
item = next(
|
|
1944
|
+
(it for it in _listify(self.load_state().get("review_items")) if it.get("id") == item_id),
|
|
1945
|
+
None,
|
|
1946
|
+
)
|
|
1947
|
+
if item is None or (workspace_id and self._record_workspace(item) != str(workspace_id)):
|
|
1948
|
+
raise FileNotFoundError(item_id)
|
|
1949
|
+
return item
|
|
1950
|
+
|
|
1951
|
+
def update_review_item(
|
|
1952
|
+
self, item_id: str, *, workspace_id: Optional[str] = None, **fields: Any,
|
|
1953
|
+
) -> Dict[str, Any]:
|
|
1954
|
+
state = self.load_state()
|
|
1955
|
+
item = next((it for it in _listify(state.get("review_items")) if it.get("id") == item_id), None)
|
|
1956
|
+
if item is None or (workspace_id and self._record_workspace(item) != str(workspace_id)):
|
|
1957
|
+
raise FileNotFoundError(item_id)
|
|
1958
|
+
for key, value in fields.items():
|
|
1959
|
+
item[key] = value
|
|
1960
|
+
item["updated_at"] = _now()
|
|
1961
|
+
self.save_state(state)
|
|
1962
|
+
self.record_timeline_event(
|
|
1963
|
+
"review", "review_item_updated",
|
|
1964
|
+
{"item_id": item_id, "status": item.get("status")},
|
|
1965
|
+
workspace_id=self._record_workspace(item),
|
|
1966
|
+
)
|
|
1967
|
+
return item
|
|
1968
|
+
|
|
1884
1969
|
def reconcile_interrupted_runs(self, *, reason: str = "server_startup") -> Dict[str, Any]:
|
|
1885
1970
|
"""Mark durable active runs as interrupted after a process restart.
|
|
1886
1971
|
|