flock-core 0.5.0b65__py3-none-any.whl → 0.5.0b70__py3-none-any.whl

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.

Potentially problematic release.


This version of flock-core might be problematic. Click here for more details.

Files changed (56) hide show
  1. flock/cli.py +74 -2
  2. flock/engines/dspy_engine.py +40 -4
  3. flock/examples.py +4 -1
  4. flock/frontend/README.md +15 -1
  5. flock/frontend/package-lock.json +2 -2
  6. flock/frontend/package.json +1 -1
  7. flock/frontend/src/App.tsx +74 -6
  8. flock/frontend/src/__tests__/e2e/critical-scenarios.test.tsx +4 -5
  9. flock/frontend/src/__tests__/integration/filtering-e2e.test.tsx +7 -3
  10. flock/frontend/src/components/filters/ArtifactTypeFilter.tsx +21 -0
  11. flock/frontend/src/components/filters/FilterFlyout.module.css +104 -0
  12. flock/frontend/src/components/filters/FilterFlyout.tsx +80 -0
  13. flock/frontend/src/components/filters/FilterPills.module.css +186 -45
  14. flock/frontend/src/components/filters/FilterPills.test.tsx +115 -99
  15. flock/frontend/src/components/filters/FilterPills.tsx +120 -44
  16. flock/frontend/src/components/filters/ProducerFilter.tsx +21 -0
  17. flock/frontend/src/components/filters/SavedFiltersControl.module.css +60 -0
  18. flock/frontend/src/components/filters/SavedFiltersControl.test.tsx +158 -0
  19. flock/frontend/src/components/filters/SavedFiltersControl.tsx +159 -0
  20. flock/frontend/src/components/filters/TagFilter.tsx +21 -0
  21. flock/frontend/src/components/filters/TimeRangeFilter.module.css +24 -0
  22. flock/frontend/src/components/filters/TimeRangeFilter.tsx +6 -1
  23. flock/frontend/src/components/filters/VisibilityFilter.tsx +21 -0
  24. flock/frontend/src/components/graph/GraphCanvas.tsx +24 -0
  25. flock/frontend/src/components/layout/DashboardLayout.css +13 -0
  26. flock/frontend/src/components/layout/DashboardLayout.tsx +8 -24
  27. flock/frontend/src/components/modules/HistoricalArtifactsModule.module.css +288 -0
  28. flock/frontend/src/components/modules/HistoricalArtifactsModule.tsx +460 -0
  29. flock/frontend/src/components/modules/HistoricalArtifactsModuleWrapper.tsx +13 -0
  30. flock/frontend/src/components/modules/ModuleRegistry.ts +7 -1
  31. flock/frontend/src/components/modules/registerModules.ts +9 -10
  32. flock/frontend/src/hooks/useModules.ts +11 -1
  33. flock/frontend/src/services/api.ts +140 -0
  34. flock/frontend/src/services/indexeddb.ts +56 -2
  35. flock/frontend/src/services/websocket.ts +129 -0
  36. flock/frontend/src/store/filterStore.test.ts +105 -185
  37. flock/frontend/src/store/filterStore.ts +173 -26
  38. flock/frontend/src/store/graphStore.test.ts +19 -0
  39. flock/frontend/src/store/graphStore.ts +166 -27
  40. flock/frontend/src/types/filters.ts +34 -1
  41. flock/frontend/src/types/graph.ts +7 -0
  42. flock/frontend/src/utils/artifacts.ts +24 -0
  43. flock/orchestrator.py +23 -1
  44. flock/service.py +146 -9
  45. flock/store.py +971 -24
  46. {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b70.dist-info}/METADATA +26 -1
  47. {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b70.dist-info}/RECORD +50 -43
  48. flock/frontend/src/components/filters/FilterBar.module.css +0 -29
  49. flock/frontend/src/components/filters/FilterBar.test.tsx +0 -133
  50. flock/frontend/src/components/filters/FilterBar.tsx +0 -33
  51. flock/frontend/src/components/modules/EventLogModule.test.tsx +0 -401
  52. flock/frontend/src/components/modules/EventLogModule.tsx +0 -396
  53. flock/frontend/src/components/modules/EventLogModuleWrapper.tsx +0 -17
  54. {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b70.dist-info}/WHEEL +0 -0
  55. {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b70.dist-info}/entry_points.txt +0 -0
  56. {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b70.dist-info}/licenses/LICENSE +0 -0
@@ -76,14 +76,79 @@ function toDashboardState(
76
76
  consumptions: Map<string, string[]>
77
77
  ): DashboardState {
78
78
  const artifacts = new Map<string, Artifact>();
79
+ const syntheticRuns = new Map(runs);
80
+
81
+ const producedBuckets = new Map<string, Set<string>>();
82
+ const consumedBuckets = new Map<string, Set<string>>();
83
+
84
+ // Helper to build bucket keys based on agent + correlation
85
+ const makeBucketKey = (agentId: string, correlationId: string) =>
86
+ `${agentId}::${correlationId || 'uncorrelated'}`;
87
+
88
+ // Track (agent, correlation) pairs that already have explicit run data
89
+ const existingRunBuckets = new Set<string>();
90
+ runs.forEach((run) => {
91
+ existingRunBuckets.add(makeBucketKey(run.agent_name, run.correlation_id));
92
+ });
79
93
 
80
94
  messages.forEach((message) => {
81
95
  artifacts.set(message.id, messageToArtifact(message, consumptions));
96
+
97
+ if (message.producedBy) {
98
+ const key = makeBucketKey(message.producedBy, message.correlationId);
99
+ if (!producedBuckets.has(key)) {
100
+ producedBuckets.set(key, new Set());
101
+ }
102
+ producedBuckets.get(key)!.add(message.id);
103
+ }
104
+ });
105
+
106
+ consumptions.forEach((consumerIds, artifactId) => {
107
+ const message = messages.get(artifactId);
108
+ const correlationId = message?.correlationId ?? '';
109
+ consumerIds.forEach((consumerId) => {
110
+ const key = makeBucketKey(consumerId, correlationId);
111
+ if (!consumedBuckets.has(key)) {
112
+ consumedBuckets.set(key, new Set());
113
+ }
114
+ consumedBuckets.get(key)!.add(artifactId);
115
+ });
116
+ });
117
+
118
+ let syntheticCounter = 0;
119
+ consumedBuckets.forEach((consumedSet, key) => {
120
+ if (consumedSet.size === 0) {
121
+ return;
122
+ }
123
+ const producedSet = producedBuckets.get(key);
124
+ if (!producedSet || producedSet.size === 0) {
125
+ return;
126
+ }
127
+
128
+ if (existingRunBuckets.has(key)) {
129
+ return;
130
+ }
131
+
132
+ const [agentIdRaw, correlationPartRaw] = key.split('::');
133
+ const agentId = agentIdRaw || 'unknown-agent';
134
+ const correlationPart = correlationPartRaw || 'uncorrelated';
135
+ const runId = `historic_${agentId}_${correlationPart}_${syntheticCounter++}`;
136
+
137
+ if (!syntheticRuns.has(runId)) {
138
+ syntheticRuns.set(runId, {
139
+ run_id: runId,
140
+ agent_name: agentId,
141
+ correlation_id: correlationPart === 'uncorrelated' ? '' : correlationPart,
142
+ status: 'completed',
143
+ consumed_artifacts: Array.from(consumedSet),
144
+ produced_artifacts: Array.from(producedSet),
145
+ });
146
+ }
82
147
  });
83
148
 
84
149
  return {
85
150
  artifacts,
86
- runs,
151
+ runs: syntheticRuns,
87
152
  consumptions, // Phase 11: Pass actual consumption data for filtered count calculation
88
153
  };
89
154
  }
@@ -251,6 +316,8 @@ export const useGraphStore = create<GraphState>()(
251
316
  const edges = deriveAgentViewEdges(dashboardState);
252
317
 
253
318
  set({ nodes, edges });
319
+ // Re-apply active filters so newly generated nodes respect current selections
320
+ useGraphStore.getState().applyFilters();
254
321
  },
255
322
 
256
323
  generateBlackboardViewGraph: () => {
@@ -289,6 +356,8 @@ export const useGraphStore = create<GraphState>()(
289
356
  timestamp: message.timestamp,
290
357
  isStreaming: message.isStreaming || false,
291
358
  streamingText: message.streamingText || '',
359
+ tags: message.tags || [],
360
+ visibilityKind: message.visibilityKind || 'Unknown',
292
361
  },
293
362
  });
294
363
  });
@@ -298,6 +367,8 @@ export const useGraphStore = create<GraphState>()(
298
367
  const edges = deriveBlackboardViewEdges(dashboardState);
299
368
 
300
369
  set({ nodes, edges });
370
+ // Ensure filters are reapplied after regeneration
371
+ useGraphStore.getState().applyFilters();
301
372
  },
302
373
 
303
374
  batchUpdate: (update) =>
@@ -312,9 +383,16 @@ export const useGraphStore = create<GraphState>()(
312
383
 
313
384
  if (update.messages) {
314
385
  const messages = new Map(state.messages);
315
- update.messages.forEach((m) => messages.set(m.id, m));
386
+ const consumptions = new Map(state.consumptions);
387
+ update.messages.forEach((m) => {
388
+ messages.set(m.id, m);
389
+ if (m.consumedBy && m.consumedBy.length > 0) {
390
+ consumptions.set(m.id, Array.from(new Set(m.consumedBy)));
391
+ }
392
+ });
316
393
  newState.messages = messages;
317
394
  newState.events = [...update.messages, ...state.events].slice(0, 100);
395
+ newState.consumptions = consumptions;
318
396
  }
319
397
 
320
398
  if (update.runs) {
@@ -327,8 +405,15 @@ export const useGraphStore = create<GraphState>()(
327
405
  }),
328
406
 
329
407
  applyFilters: () => {
330
- const { nodes, edges, messages } = get();
331
- const { correlationId, timeRange } = useFilterStore.getState();
408
+ const { nodes, edges, messages, consumptions } = get();
409
+ const {
410
+ correlationId,
411
+ timeRange,
412
+ selectedArtifactTypes,
413
+ selectedProducers,
414
+ selectedTags,
415
+ selectedVisibility,
416
+ } = useFilterStore.getState();
332
417
 
333
418
  // Helper to calculate time range boundaries
334
419
  const getTimeRangeBoundaries = (): { start: number; end: number } => {
@@ -342,64 +427,118 @@ export const useGraphStore = create<GraphState>()(
342
427
  } else if (timeRange.preset === 'custom' && timeRange.start && timeRange.end) {
343
428
  return { start: timeRange.start, end: timeRange.end };
344
429
  }
345
- return { start: now - 10 * 60 * 1000, end: now };
430
+ return { start: Number.NEGATIVE_INFINITY, end: Number.POSITIVE_INFINITY };
346
431
  };
347
432
 
348
433
  const { start: timeStart, end: timeEnd } = getTimeRangeBoundaries();
349
434
 
350
- // Filter messages based on correlation ID and time range
351
435
  const visibleMessageIds = new Set<string>();
436
+ const producedStats = new Map<string, { total: number; byType: Record<string, number> }>();
437
+ const consumedStats = new Map<string, { total: number; byType: Record<string, number> }>();
438
+
439
+ const incrementStat = (
440
+ map: Map<string, { total: number; byType: Record<string, number> }>,
441
+ key: string,
442
+ type: string
443
+ ) => {
444
+ if (!map.has(key)) {
445
+ map.set(key, { total: 0, byType: {} });
446
+ }
447
+ const entry = map.get(key)!;
448
+ entry.total += 1;
449
+ entry.byType[type] = (entry.byType[type] || 0) + 1;
450
+ };
451
+
352
452
  messages.forEach((message) => {
353
453
  let visible = true;
354
454
 
355
- // Apply correlation ID filter (selective)
356
455
  if (correlationId && message.correlationId !== correlationId) {
357
456
  visible = false;
358
457
  }
359
458
 
360
- // Apply time range filter (in-memory)
361
459
  if (visible && (message.timestamp < timeStart || message.timestamp > timeEnd)) {
362
460
  visible = false;
363
461
  }
364
462
 
463
+ if (
464
+ visible &&
465
+ selectedArtifactTypes.length > 0 &&
466
+ !selectedArtifactTypes.includes(message.type)
467
+ ) {
468
+ visible = false;
469
+ }
470
+
471
+ if (
472
+ visible &&
473
+ selectedProducers.length > 0 &&
474
+ !selectedProducers.includes(message.producedBy)
475
+ ) {
476
+ visible = false;
477
+ }
478
+
479
+ if (
480
+ visible &&
481
+ selectedVisibility.length > 0 &&
482
+ !selectedVisibility.includes(message.visibilityKind || 'Unknown')
483
+ ) {
484
+ visible = false;
485
+ }
486
+
487
+ if (visible && selectedTags.length > 0) {
488
+ const messageTags = message.tags || [];
489
+ const hasAllTags = selectedTags.every((tag) => messageTags.includes(tag));
490
+ if (!hasAllTags) {
491
+ visible = false;
492
+ }
493
+ }
494
+
365
495
  if (visible) {
366
496
  visibleMessageIds.add(message.id);
497
+ incrementStat(producedStats, message.producedBy, message.type);
498
+
499
+ const consumers = consumptions.get(message.id) || [];
500
+ consumers.forEach((consumerId) => {
501
+ incrementStat(consumedStats, consumerId, message.type);
502
+ });
367
503
  }
368
504
  });
369
505
 
370
- // Update nodes visibility
371
506
  const updatedNodes = nodes.map((node) => {
372
507
  if (node.type === 'message') {
373
- // For message nodes, check if message is visible
374
508
  return {
375
509
  ...node,
376
510
  hidden: !visibleMessageIds.has(node.id),
377
511
  };
378
- } else if (node.type === 'agent') {
379
- // For agent nodes, show if any visible messages involve this agent
380
- let hasVisibleMessages = false;
381
- messages.forEach((message) => {
382
- if (visibleMessageIds.has(message.id)) {
383
- if (message.producedBy === node.id) {
384
- hasVisibleMessages = true;
385
- }
386
- }
387
- });
512
+ }
513
+ if (node.type === 'agent') {
514
+ const produced = producedStats.get(node.id);
515
+ const consumed = consumedStats.get(node.id);
516
+ const currentData = node.data as AgentNodeData;
388
517
  return {
389
518
  ...node,
390
- hidden: !hasVisibleMessages,
519
+ hidden: false,
520
+ data: {
521
+ ...node.data,
522
+ sentCount: produced?.total ?? currentData.sentCount ?? 0,
523
+ recvCount: consumed?.total ?? currentData.recvCount ?? 0,
524
+ sentByType: produced?.byType ?? currentData.sentByType ?? {},
525
+ receivedByType: consumed?.byType ?? currentData.receivedByType ?? {},
526
+ },
391
527
  };
392
528
  }
393
529
  return node;
394
530
  });
395
531
 
396
- // Update edges visibility
397
532
  const updatedEdges = edges.map((edge) => {
398
- // Hide edge if either source or target node is hidden
399
- const sourceNode = updatedNodes.find((n) => n.id === edge.source);
400
- const targetNode = updatedNodes.find((n) => n.id === edge.target);
401
- const hidden = sourceNode?.hidden || targetNode?.hidden || false;
402
-
533
+ let hidden = edge.hidden ?? false;
534
+ const data: any = edge.data;
535
+ if (data && Array.isArray(data.artifactIds) && data.artifactIds.length > 0) {
536
+ hidden = data.artifactIds.every((artifactId: string) => !visibleMessageIds.has(artifactId));
537
+ } else {
538
+ const sourceNode = updatedNodes.find((n) => n.id === edge.source);
539
+ const targetNode = updatedNodes.find((n) => n.id === edge.target);
540
+ hidden = !!(sourceNode?.hidden || targetNode?.hidden);
541
+ }
403
542
  return {
404
543
  ...edge,
405
544
  hidden,
@@ -1,4 +1,4 @@
1
- export type TimeRangePreset = 'last5min' | 'last10min' | 'last1hour' | 'custom';
1
+ export type TimeRangePreset = 'last5min' | 'last10min' | 'last1hour' | 'custom' | 'all';
2
2
 
3
3
  export interface TimeRange {
4
4
  preset: TimeRangePreset;
@@ -12,3 +12,36 @@ export interface CorrelationIdMetadata {
12
12
  artifact_count: number;
13
13
  run_count: number;
14
14
  }
15
+
16
+ export interface FilterFacets {
17
+ artifactTypes: string[];
18
+ producers: string[];
19
+ tags: string[];
20
+ visibilities: string[];
21
+ }
22
+
23
+ export interface ArtifactSummary {
24
+ total: number;
25
+ by_type: Record<string, number>;
26
+ by_producer: Record<string, number>;
27
+ by_visibility: Record<string, number>;
28
+ tag_counts: Record<string, number>;
29
+ earliest_created_at: string | null;
30
+ latest_created_at: string | null;
31
+ }
32
+
33
+ export interface FilterSnapshot {
34
+ correlationId: string | null;
35
+ timeRange: TimeRange;
36
+ artifactTypes: string[];
37
+ producers: string[];
38
+ tags: string[];
39
+ visibility: string[];
40
+ }
41
+
42
+ export interface SavedFilterMeta {
43
+ filter_id: string;
44
+ name: string;
45
+ created_at: number;
46
+ filters: FilterSnapshot;
47
+ }
@@ -22,8 +22,13 @@ export interface Message {
22
22
  timestamp: number;
23
23
  correlationId: string;
24
24
  producedBy: string;
25
+ tags?: string[];
26
+ visibilityKind?: string;
27
+ partitionKey?: string | null;
28
+ version?: number;
25
29
  isStreaming?: boolean; // True while streaming, false when complete
26
30
  streamingText?: string; // Accumulated streaming text (raw)
31
+ consumedBy?: string[];
27
32
  }
28
33
 
29
34
  export interface AgentNodeData extends Record<string, unknown> {
@@ -47,6 +52,8 @@ export interface MessageNodeData extends Record<string, unknown> {
47
52
  timestamp: number;
48
53
  isStreaming?: boolean; // True while streaming tokens
49
54
  streamingText?: string; // Raw streaming text
55
+ tags?: string[];
56
+ visibilityKind?: string;
50
57
  }
51
58
 
52
59
  export type AgentViewNode = Node<AgentNodeData, 'agent'>;
@@ -0,0 +1,24 @@
1
+ import type { ArtifactListItem } from '../services/api';
2
+ import type { Message } from '../types/graph';
3
+
4
+ export function mapArtifactToMessage(item: ArtifactListItem): Message {
5
+ const consumedBy =
6
+ item.consumed_by ??
7
+ (item.consumptions ? Array.from(new Set(item.consumptions.map((record) => record.consumer))) : []);
8
+
9
+ return {
10
+ id: item.id,
11
+ type: item.type,
12
+ payload: item.payload,
13
+ timestamp: new Date(item.created_at).getTime(),
14
+ correlationId: item.correlation_id ?? '',
15
+ producedBy: item.produced_by,
16
+ tags: item.tags || [],
17
+ visibilityKind: item.visibility_kind || item.visibility?.kind || 'Unknown',
18
+ partitionKey: item.partition_key ?? null,
19
+ version: item.version ?? 1,
20
+ isStreaming: false,
21
+ streamingText: '',
22
+ consumedBy,
23
+ };
24
+ }
flock/orchestrator.py CHANGED
@@ -3,10 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import logging
6
7
  import os
7
8
  from asyncio import Task
8
9
  from collections.abc import Iterable, Mapping, Sequence
9
10
  from contextlib import asynccontextmanager
11
+ from datetime import datetime, timezone
10
12
  from typing import TYPE_CHECKING, Any, AsyncGenerator
11
13
  from uuid import uuid4
12
14
 
@@ -27,7 +29,7 @@ from flock.mcp import (
27
29
  )
28
30
  from flock.registry import type_registry
29
31
  from flock.runtime import Context
30
- from flock.store import BlackboardStore, InMemoryBlackboardStore
32
+ from flock.store import BlackboardStore, ConsumptionRecord, InMemoryBlackboardStore
31
33
  from flock.visibility import AgentIdentity, PublicVisibility, Visibility
32
34
 
33
35
 
@@ -110,6 +112,7 @@ class Flock(metaclass=AutoTracedMeta):
110
112
  ... )
111
113
  """
112
114
  self._patch_litellm_proxy_imports()
115
+ self._logger = logging.getLogger(__name__)
113
116
  self.model = model
114
117
  self.store: BlackboardStore = store or InMemoryBlackboardStore()
115
118
  self._agents: dict[str, Agent] = {}
@@ -891,6 +894,25 @@ class Flock(metaclass=AutoTracedMeta):
891
894
  self._record_agent_run(agent)
892
895
  await agent.execute(ctx, artifacts)
893
896
 
897
+ if artifacts:
898
+ try:
899
+ timestamp = datetime.now(timezone.utc)
900
+ records = [
901
+ ConsumptionRecord(
902
+ artifact_id=artifact.id,
903
+ consumer=agent.name,
904
+ run_id=ctx.task_id,
905
+ correlation_id=str(correlation_id) if correlation_id else None,
906
+ consumed_at=timestamp,
907
+ )
908
+ for artifact in artifacts
909
+ ]
910
+ await self.store.record_consumptions(records)
911
+ except NotImplementedError:
912
+ pass
913
+ except Exception as exc: # pragma: no cover - defensive logging
914
+ self._logger.exception("Failed to record artifact consumption: %s", exc)
915
+
894
916
  # Helpers --------------------------------------------------------------
895
917
 
896
918
  def _normalize_input(
flock/service.py CHANGED
@@ -3,13 +3,15 @@ from __future__ import annotations
3
3
 
4
4
  """HTTP control plane for the blackboard orchestrator."""
5
5
 
6
+ from datetime import datetime
6
7
  from typing import TYPE_CHECKING, Any
7
8
  from uuid import UUID
8
9
 
9
- from fastapi import FastAPI, HTTPException
10
+ from fastapi import FastAPI, HTTPException, Query
10
11
  from fastapi.responses import PlainTextResponse
11
12
 
12
13
  from flock.registry import type_registry
14
+ from flock.store import ArtifactEnvelope, ConsumptionRecord, FilterConfig
13
15
 
14
16
 
15
17
  if TYPE_CHECKING:
@@ -26,6 +28,64 @@ class BlackboardHTTPService:
26
28
  app = self.app
27
29
  orchestrator = self.orchestrator
28
30
 
31
+ def _serialize_artifact(
32
+ artifact,
33
+ consumptions: list[ConsumptionRecord] | None = None,
34
+ ) -> dict[str, Any]:
35
+ data = {
36
+ "id": str(artifact.id),
37
+ "type": artifact.type,
38
+ "payload": artifact.payload,
39
+ "produced_by": artifact.produced_by,
40
+ "visibility": artifact.visibility.model_dump(mode="json"),
41
+ "visibility_kind": getattr(artifact.visibility, "kind", "Unknown"),
42
+ "created_at": artifact.created_at.isoformat(),
43
+ "correlation_id": str(artifact.correlation_id) if artifact.correlation_id else None,
44
+ "partition_key": artifact.partition_key,
45
+ "tags": sorted(artifact.tags),
46
+ "version": artifact.version,
47
+ }
48
+ if consumptions is not None:
49
+ data["consumptions"] = [
50
+ {
51
+ "artifact_id": str(record.artifact_id),
52
+ "consumer": record.consumer,
53
+ "run_id": record.run_id,
54
+ "correlation_id": record.correlation_id,
55
+ "consumed_at": record.consumed_at.isoformat(),
56
+ }
57
+ for record in consumptions
58
+ ]
59
+ data["consumed_by"] = sorted({record.consumer for record in consumptions})
60
+ return data
61
+
62
+ def _parse_datetime(value: str | None, label: str) -> datetime | None:
63
+ if value is None:
64
+ return None
65
+ try:
66
+ return datetime.fromisoformat(value)
67
+ except ValueError as exc: # pragma: no cover - FastAPI converts
68
+ raise HTTPException(status_code=400, detail=f"Invalid {label}: {value}") from exc
69
+
70
+ def _make_filter_config(
71
+ type_names: list[str] | None,
72
+ produced_by: list[str] | None,
73
+ correlation_id: str | None,
74
+ tags: list[str] | None,
75
+ visibility: list[str] | None,
76
+ start: str | None,
77
+ end: str | None,
78
+ ) -> FilterConfig:
79
+ return FilterConfig(
80
+ type_names=set(type_names) if type_names else None,
81
+ produced_by=set(produced_by) if produced_by else None,
82
+ correlation_id=correlation_id,
83
+ tags=set(tags) if tags else None,
84
+ visibility=set(visibility) if visibility else None,
85
+ start=_parse_datetime(start, "from"),
86
+ end=_parse_datetime(end, "to"),
87
+ )
88
+
29
89
  @app.post("/api/v1/artifacts")
30
90
  async def publish_artifact(body: dict[str, Any]) -> dict[str, str]:
31
91
  type_name = body.get("type")
@@ -38,19 +98,73 @@ class BlackboardHTTPService:
38
98
  raise HTTPException(status_code=400, detail=str(exc)) from exc
39
99
  return {"status": "accepted"}
40
100
 
101
+ @app.get("/api/v1/artifacts")
102
+ async def list_artifacts(
103
+ type_names: list[str] | None = Query(None, alias="type"),
104
+ produced_by: list[str] | None = Query(None),
105
+ correlation_id: str | None = None,
106
+ tag: list[str] | None = Query(None),
107
+ start: str | None = Query(None, alias="from"),
108
+ end: str | None = Query(None, alias="to"),
109
+ visibility: list[str] | None = Query(None),
110
+ limit: int = Query(50, ge=1, le=500),
111
+ offset: int = Query(0, ge=0),
112
+ embed_meta: bool = Query(False, alias="embed_meta"),
113
+ ) -> dict[str, Any]:
114
+ filters = _make_filter_config(
115
+ type_names,
116
+ produced_by,
117
+ correlation_id,
118
+ tag,
119
+ visibility,
120
+ start,
121
+ end,
122
+ )
123
+ artifacts, total = await orchestrator.store.query_artifacts(
124
+ filters,
125
+ limit=limit,
126
+ offset=offset,
127
+ embed_meta=embed_meta,
128
+ )
129
+ items: list[dict[str, Any]] = []
130
+ for artifact in artifacts:
131
+ if isinstance(artifact, ArtifactEnvelope):
132
+ items.append(_serialize_artifact(artifact.artifact, artifact.consumptions))
133
+ else:
134
+ items.append(_serialize_artifact(artifact))
135
+ return {
136
+ "items": items,
137
+ "pagination": {"limit": limit, "offset": offset, "total": total},
138
+ }
139
+
140
+ @app.get("/api/v1/artifacts/summary")
141
+ async def summarize_artifacts(
142
+ type_names: list[str] | None = Query(None, alias="type"),
143
+ produced_by: list[str] | None = Query(None),
144
+ correlation_id: str | None = None,
145
+ tag: list[str] | None = Query(None),
146
+ start: str | None = Query(None, alias="from"),
147
+ end: str | None = Query(None, alias="to"),
148
+ visibility: list[str] | None = Query(None),
149
+ ) -> dict[str, Any]:
150
+ filters = _make_filter_config(
151
+ type_names,
152
+ produced_by,
153
+ correlation_id,
154
+ tag,
155
+ visibility,
156
+ start,
157
+ end,
158
+ )
159
+ summary = await orchestrator.store.summarize_artifacts(filters)
160
+ return {"summary": summary}
161
+
41
162
  @app.get("/api/v1/artifacts/{artifact_id}")
42
163
  async def get_artifact(artifact_id: UUID) -> dict[str, Any]:
43
164
  artifact = await orchestrator.store.get(artifact_id)
44
165
  if artifact is None:
45
166
  raise HTTPException(status_code=404, detail="artifact not found")
46
- return {
47
- "id": str(artifact.id),
48
- "type": artifact.type,
49
- "payload": artifact.payload,
50
- "produced_by": artifact.produced_by,
51
- "visibility": artifact.visibility.model_dump(mode="json"),
52
- "created_at": artifact.created_at.isoformat(),
53
- }
167
+ return _serialize_artifact(artifact)
54
168
 
55
169
  @app.post("/api/v1/agents/{name}/run")
56
170
  async def run_agent(name: str, body: dict[str, Any]) -> dict[str, Any]:
@@ -110,6 +224,29 @@ class BlackboardHTTPService:
110
224
  ]
111
225
  }
112
226
 
227
+ @app.get("/api/v1/agents/{agent_id}/history-summary")
228
+ async def agent_history(
229
+ agent_id: str,
230
+ type_names: list[str] | None = Query(None, alias="type"),
231
+ produced_by: list[str] | None = Query(None),
232
+ correlation_id: str | None = None,
233
+ tag: list[str] | None = Query(None),
234
+ start: str | None = Query(None, alias="from"),
235
+ end: str | None = Query(None, alias="to"),
236
+ visibility: list[str] | None = Query(None),
237
+ ) -> dict[str, Any]:
238
+ filters = _make_filter_config(
239
+ type_names,
240
+ produced_by,
241
+ correlation_id,
242
+ tag,
243
+ visibility,
244
+ start,
245
+ end,
246
+ )
247
+ summary = await orchestrator.store.agent_history_summary(agent_id, filters)
248
+ return {"agent_id": agent_id, "summary": summary}
249
+
113
250
  @app.get("/health")
114
251
  async def health() -> dict[str, str]: # pragma: no cover - trivial
115
252
  return {"status": "ok"}