Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 59 additions & 18 deletions packages/escurel_explorer_kit/lib/client/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,30 @@ class SkillSummary {
}

class InstanceSummary {
const InstanceSummary({required this.id, required this.skill, required this.frontmatter});
const InstanceSummary({
required this.id,
required this.skill,
required this.frontmatter,
});

final String id;
final String skill;
final Map<String, dynamic> frontmatter;

/// Whether this instance is a tombstone (erased/revoked on user
/// request). Carl's `erase_member` writes `status: erased` on the member
/// and `status: revoked` on its consent; both keep their required keys so
/// the page still parses. Treated as a deletion marker the explorer hides
/// by default.
bool get erased => isErasedFrontmatter(frontmatter);
}

/// `true` when a page's frontmatter marks it as a tombstone. Shared by
/// instance summaries and expanded pages so the "hide erased" rule is
/// defined in exactly one place.
bool isErasedFrontmatter(Map<String, dynamic> frontmatter) {
final status = (frontmatter['status'] as String?)?.trim().toLowerCase();
return status == 'erased' || status == 'revoked';
}

// ── events / inbox (M7) ─────────────────────────────────────────
Expand Down Expand Up @@ -199,17 +218,17 @@ class Event {
bool get isInbox => status == 'inbox';

static Event fromJson(Map<String, dynamic> j) => Event(
eventId: (j['event_id'] as String?) ?? '',
at: j['at'] as String?,
source: (j['source'] as String?) ?? '',
mime: (j['mime'] as String?) ?? '',
labelSkill: (j['label_skill'] as String?) ?? '',
instancePageId: j['instance_page_id'] as String?,
status: (j['status'] as String?) ?? 'inbox',
title: (j['title'] as String?) ?? '',
body: (j['body'] as String?) ?? '',
provenance: Map<String, dynamic>.from(j['provenance'] as Map? ?? const {}),
);
eventId: (j['event_id'] as String?) ?? '',
at: j['at'] as String?,
source: (j['source'] as String?) ?? '',
mime: (j['mime'] as String?) ?? '',
labelSkill: (j['label_skill'] as String?) ?? '',
instancePageId: j['instance_page_id'] as String?,
status: (j['status'] as String?) ?? 'inbox',
title: (j['title'] as String?) ?? '',
body: (j['body'] as String?) ?? '',
provenance: Map<String, dynamic>.from(j['provenance'] as Map? ?? const {}),
);
}

// ── run_stored_query ────────────────────────────────────────────
Expand Down Expand Up @@ -269,7 +288,12 @@ class UpdateResult {
// ── live mode (session) — stubs until M3 transport decided ──────

class Session {
const Session({required this.id, required this.pageId, required this.headVersion, required this.content});
const Session({
required this.id,
required this.pageId,
required this.headVersion,
required this.content,
});
final String id;
final String pageId;
final String headVersion;
Expand All @@ -295,7 +319,11 @@ class CloseResult {
}

class AwarenessEvent {
const AwarenessEvent({required this.session, required this.kind, this.payload});
const AwarenessEvent({
required this.session,
required this.kind,
this.payload,
});
final String session;
final String kind;
final Map<String, Object?>? payload;
Expand All @@ -304,21 +332,33 @@ class AwarenessEvent {
// ── admin MCP tools — gated by escurel-admin role ───────────────

class LaneSummary {
const LaneSummary({required this.name, required this.backend, required this.tenantsPresent});
const LaneSummary({
required this.name,
required this.backend,
required this.tenantsPresent,
});
final String name;
final String backend;
final int tenantsPresent;
}

class LaneKey {
const LaneKey({required this.key, required this.sizeBytes, this.lastModified});
const LaneKey({
required this.key,
required this.sizeBytes,
this.lastModified,
});
final String key;
final int sizeBytes;
final DateTime? lastModified;
}

class LaneBlob {
const LaneBlob({required this.key, required this.bytes, required this.contentType});
const LaneBlob({
required this.key,
required this.bytes,
required this.contentType,
});
final String key;
final Uint8List bytes;
final String contentType;
Expand Down Expand Up @@ -423,5 +463,6 @@ class AuditDrift {
final List<String> markdownNotInDuckdb;
final List<String> indexedButNoMarkdown;

bool get isClean => markdownNotInDuckdb.isEmpty && indexedButNoMarkdown.isEmpty;
bool get isClean =>
markdownNotInDuckdb.isEmpty && indexedButNoMarkdown.isEmpty;
}
86 changes: 86 additions & 0 deletions packages/escurel_explorer_kit/lib/crm/auto_refresh.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/// Auto-refresh: keep the explorer's read views current without a manual
/// reload (F5). All read data is served by `FutureProvider`s, which are
/// pull-once + cache; a periodic timer invalidates them so they re-fetch.
///
/// On by default — the operator browses a live knowledge base. A toggle +
/// interval let an operator pause it (e.g. while reading) or tune the
/// cadence. This is the pragmatic v1; an event-driven path over Escurel's
/// `/ws` (the client's `awareness()` stream) can replace the poll later.
library;

import 'dart:async';

import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../state/providers.dart';
import 'crm_providers.dart';

/// Whether the explorer polls for changes. Default `true`.
final autoRefreshEnabledProvider = StateProvider<bool>((ref) => true);

/// How often to re-fetch when [autoRefreshEnabledProvider] is on.
final autoRefreshIntervalProvider = StateProvider<Duration>(
(ref) => const Duration(seconds: 15),
);

/// Invalidate the explorer's read providers so they re-fetch from the
/// backend. Pure cache-busting — the providers that something is watching
/// re-resolve; idle ones stay lazy. Safe to call repeatedly.
void refreshExplorerData(WidgetRef ref) {
ref.invalidate(allInstancesRawProvider);
ref.invalidate(skillsCatalogueProvider);
ref.invalidate(currentPageProvider);
ref.invalidate(currentBacklinksProvider);
ref.invalidate(currentOutgoingLinksProvider);
ref.invalidate(entityEventHistoryProvider);
ref.invalidate(inboxEventsProvider);
ref.invalidate(instanceSnapshotsProvider);
}

/// Drives [refreshExplorerData] on a timer while mounted. Invisible — wraps
/// the workspace and re-arms whenever the enabled flag or interval changes.
class AutoRefresher extends ConsumerStatefulWidget {
const AutoRefresher({super.key, required this.child});
final Widget child;

@override
ConsumerState<AutoRefresher> createState() => _AutoRefresherState();
}

class _AutoRefresherState extends ConsumerState<AutoRefresher> {
Timer? _timer;

void _reconfigure() {
_timer?.cancel();
_timer = null;
if (!ref.read(autoRefreshEnabledProvider)) return;
final interval = ref.read(autoRefreshIntervalProvider);
_timer = Timer.periodic(interval, (_) {
if (mounted) refreshExplorerData(ref);
});
}

@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _reconfigure();
});
}

@override
Widget build(BuildContext context) {
// Re-arm the timer whenever the operator toggles polling or changes the
// cadence.
ref.listen(autoRefreshEnabledProvider, (_, _) => _reconfigure());
ref.listen(autoRefreshIntervalProvider, (_, _) => _reconfigure());
return widget.child;
}

@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
Loading
Loading