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.
@@ -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>((initialTab as ActTab) || "agents");
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 (
@@ -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" },
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "5.4.0"
29
+ __version__ = "5.6.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.4.0"
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")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "5.4.0"
3
+ __version__ = "5.6.0"
@@ -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
@@ -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,
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "5.4.0"
14
+ MARKETPLACE_VERSION = "5.6.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -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.4.0"
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