fix(flows): only offer install for capabilities the store resolves
Some checks are pending
Security / Security check (push) Waiting to run
Some checks are pending
Security / Security check (push) Waiting to run
The capability set behind the flow editor's Install quick-fix and the flow list's install badge ingested every store entry's requiresCapabilities (dependencies, not provided capabilities) and ignored entry status/kind. Clicking Install on such a capability ended in the hub's "no store entry for '<name>'" error. The set now mirrors the hub's install resolver (entry names only, no planned or federated entries) via installableStoreCapabilities() with unit tests for both classification states. Unresolvable capabilities render the editor's 'not in store' state, which explains the three recovery paths before any click; the editor pin moves to 0.25.0 (commit-pinned until its tag exists) and the dialog harness captures the badge states light+dark. Studio 0.78.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
bef2dbe988
commit
08227410e6
8 changed files with 203 additions and 16 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -6,6 +6,23 @@ lockstep.
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Fixed — honest install badge in the flow editor (0.78.0)
|
||||||
|
|
||||||
|
- **Install is only offered when the store can deliver.** The
|
||||||
|
capability set behind the flow editor's Install quick-fix and the
|
||||||
|
flow list's install badge previously ingested every store entry's
|
||||||
|
`requiresCapabilities` (dependencies, not provided capabilities)
|
||||||
|
and ignored entry status/kind — so clicking Install on such a
|
||||||
|
capability ended in the hub's "no store entry for '<name>'" error.
|
||||||
|
The set now mirrors the hub's install resolver: entry names only,
|
||||||
|
no `planned` entries, no federated entries
|
||||||
|
(`lib/data/store_caps.dart`). Capabilities the store cannot
|
||||||
|
resolve render the editor's "not in store" state, which explains
|
||||||
|
the three recovery paths (local install, add store, configure
|
||||||
|
integration) before any click.
|
||||||
|
- Flow editor pinned at 0.25.0 (store-resolvability split in the
|
||||||
|
flow list, three-path analyzer message).
|
||||||
|
|
||||||
### Added — real trust + exposure data (0.77.0)
|
### Added — real trust + exposure data (0.77.0)
|
||||||
|
|
||||||
- **Per-entry install verification.** The hub now reports (since
|
- **Per-entry install verification.** The hub now reports (since
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:integration_test/integration_test.dart';
|
import 'package:integration_test/integration_test.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chain_studio_flow_editor/src/l10n.dart';
|
||||||
|
import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart';
|
||||||
|
|
||||||
import 'package:chain_studio/data/hub.dart' show StoreItem;
|
import 'package:chain_studio/data/hub.dart' show StoreItem;
|
||||||
import 'package:chain_studio/l10n/app_localizations.dart';
|
import 'package:chain_studio/l10n/app_localizations.dart';
|
||||||
import 'package:chain_studio/pages/federation.dart';
|
import 'package:chain_studio/pages/federation.dart';
|
||||||
|
|
@ -104,6 +107,65 @@ void main() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flow-list resolvability badge, both classified states side by
|
||||||
|
// side: store-resolvable missing capability (amber chip +
|
||||||
|
// Install link) vs. capability no store can install (quiet
|
||||||
|
// "nicht im Store" chip; recovery paths live in its tooltip).
|
||||||
|
for (final (themeName, mode) in [
|
||||||
|
('light', ThemeMode.light),
|
||||||
|
('dark', ThemeMode.dark),
|
||||||
|
]) {
|
||||||
|
testWidgets('flow-list badge states — $themeName', (tester) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
themeMode: mode,
|
||||||
|
theme: ThemeData.light(useMaterial3: true),
|
||||||
|
darkTheme: ThemeData.dark(useMaterial3: true),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: const Locale('de'),
|
||||||
|
builder: (context, child) =>
|
||||||
|
RepaintBoundary(key: _shotKey, child: child),
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 260),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MissingModulesBadge(
|
||||||
|
installable: const ['text.extract'],
|
||||||
|
notInStore: const [],
|
||||||
|
strings:
|
||||||
|
const FlowEditorStrings(FlowEditorLocale.de),
|
||||||
|
onInstall: () {},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
MissingModulesBadge(
|
||||||
|
installable: const [],
|
||||||
|
notInStore: const ['acme.internal/lookup'],
|
||||||
|
strings:
|
||||||
|
const FlowEditorStrings(FlowEditorLocale.de),
|
||||||
|
onInstall: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
await _shot(tester, 'flow-badge-states-$themeName');
|
||||||
|
expect(find.text('1 Modul fehlt'), findsOneWidget);
|
||||||
|
expect(find.text('Installieren'), findsOneWidget);
|
||||||
|
expect(find.text('nicht im Store'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Trust gate with the hub-reported verification statement — the
|
// Trust gate with the hub-reported verification statement — the
|
||||||
// pinned-key (good) and policy-off (warning) variants.
|
// pinned-key (good) and policy-off (warning) variants.
|
||||||
for (final (name, verification) in [
|
for (final (name, verification) in [
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
/// Studio's own build version. Bump on every UI release so the
|
/// Studio's own build version. Bump on every UI release so the
|
||||||
/// running app self-identifies.
|
/// running app self-identifies.
|
||||||
const String kStudioVersion = '0.77.0';
|
const String kStudioVersion = '0.78.0';
|
||||||
|
|
||||||
const String kProductName = 'Ch∆In Studio';
|
const String kProductName = 'Ch∆In Studio';
|
||||||
const String kVendorName = 'Flemming.AI (F∆I)';
|
const String kVendorName = 'Flemming.AI (F∆I)';
|
||||||
|
|
|
||||||
30
lib/data/store_caps.dart
Normal file
30
lib/data/store_caps.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// Store-resolvability: which capabilities can the store actually
|
||||||
|
// install? This is the data source behind the flow editor's
|
||||||
|
// "Install" quick-fix and the flow list's install badge — it must
|
||||||
|
// mirror the hub's install resolver (exact entry name, an
|
||||||
|
// installable version, native bundle), or the UI offers installs
|
||||||
|
// that end in "no store entry for '<name>'".
|
||||||
|
|
||||||
|
import 'hub.dart';
|
||||||
|
|
||||||
|
/// Bare capability names a store entry can install right now.
|
||||||
|
///
|
||||||
|
/// Mirrors the hub's `resolve_install_plan` acceptance rules:
|
||||||
|
/// - only the entry's own `name` counts — `requiresCapabilities`
|
||||||
|
/// are the entry's DEPENDENCIES, not things it provides, and
|
||||||
|
/// must never be offered as installable;
|
||||||
|
/// - `planned` entries have no downloadable version yet;
|
||||||
|
/// - `federated` entries route through a bridge (MCP/n8n), not
|
||||||
|
/// the bundle-install path.
|
||||||
|
/// An empty `status` (older hub without the field) keeps the
|
||||||
|
/// entry installable — the classified install error is the net
|
||||||
|
/// underneath that skew.
|
||||||
|
Set<String> installableStoreCapabilities(Iterable<StoreItem> items) {
|
||||||
|
final caps = <String>{};
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.isFederated) continue;
|
||||||
|
if (item.status == 'planned') continue;
|
||||||
|
caps.add(item.name);
|
||||||
|
}
|
||||||
|
return caps;
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../data/error_presentation.dart';
|
import '../data/error_presentation.dart';
|
||||||
import '../data/flow_run_driver.dart';
|
import '../data/flow_run_driver.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
|
import '../data/store_caps.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/chain_install_confirm.dart';
|
import '../widgets/chain_install_confirm.dart';
|
||||||
|
|
@ -55,18 +56,14 @@ class _FlowsPageState extends State<FlowsPage> {
|
||||||
try {
|
try {
|
||||||
final items = await HubService.instance.searchStore(limit: 500);
|
final items = await HubService.instance.searchStore(limit: 500);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Store items carry a list of provided capabilities. We
|
// Only capabilities the store can actually install — the
|
||||||
// ingest all of them flat so a single store entry
|
// editor's Install quick-fix and list badge promise exactly
|
||||||
// shipping multiple caps still reports each as installable.
|
// what the hub's install resolver accepts. (This set used to
|
||||||
// Falls back to the item name for legacy entries.
|
// ingest requiresCapabilities too, which offered installs
|
||||||
final caps = <String>{};
|
// that ended in "no store entry for '<name>'".)
|
||||||
for (final item in items) {
|
setState(
|
||||||
if (item.requiresCapabilities.isNotEmpty) {
|
() => _storeCaps = installableStoreCapabilities(items).toList()..sort(),
|
||||||
caps.addAll(item.requiresCapabilities);
|
);
|
||||||
}
|
|
||||||
caps.add(item.name);
|
|
||||||
}
|
|
||||||
setState(() => _storeCaps = caps.toList()..sort());
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Soft-fail: empty list disables the Install button on
|
// Soft-fail: empty list disables the Install button on
|
||||||
// unknown-cap fixes, but Add-source remains available.
|
// unknown-cap fixes, but Add-source remains available.
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ packages:
|
||||||
path: "../fai_chain_studio_flow_editor"
|
path: "../fai_chain_studio_flow_editor"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.24.1"
|
version: "0.25.0"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: chain_studio
|
name: chain_studio
|
||||||
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.77.0
|
version: 0.78.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
@ -45,7 +45,9 @@ dependencies:
|
||||||
# Tag-pinned so a CI (override-free) build is reproducible and an
|
# Tag-pinned so a CI (override-free) build is reproducible and an
|
||||||
# editor push can't retroactively change what a Studio release
|
# editor push can't retroactively change what a Studio release
|
||||||
# builds against. Bump this in lockstep with the editor tag.
|
# builds against. Bump this in lockstep with the editor tag.
|
||||||
ref: v0.24.1
|
# Commit-pinned to editor 0.25.0 until its v0.25.0 tag exists
|
||||||
|
# (equally reproducible); switch back to the tag then.
|
||||||
|
ref: ab97e5e
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
79
test/store_caps_test.dart
Normal file
79
test/store_caps_test.dart
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import 'package:chain_studio/data/hub.dart';
|
||||||
|
import 'package:chain_studio/data/store_caps.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
StoreItem _item(
|
||||||
|
String name, {
|
||||||
|
String status = 'published',
|
||||||
|
String kind = 'native',
|
||||||
|
List<String> requires = const [],
|
||||||
|
}) {
|
||||||
|
return StoreItem(
|
||||||
|
name: name,
|
||||||
|
taglineEn: '',
|
||||||
|
taglineDe: '',
|
||||||
|
descriptionEn: '',
|
||||||
|
descriptionDe: '',
|
||||||
|
category: 'data',
|
||||||
|
tags: const [],
|
||||||
|
requiresCapabilities: requires,
|
||||||
|
requiresServices: const [],
|
||||||
|
license: 'Apache-2.0',
|
||||||
|
repository: '',
|
||||||
|
bestVersion: '1.0.0',
|
||||||
|
status: status,
|
||||||
|
installed: false,
|
||||||
|
featured: false,
|
||||||
|
iconUrl: '',
|
||||||
|
screenshotUrls: const [],
|
||||||
|
docsUrl: '',
|
||||||
|
kind: kind,
|
||||||
|
provider: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('installableStoreCapabilities', () {
|
||||||
|
test('published and alpha entries are installable by name', () {
|
||||||
|
final caps = installableStoreCapabilities([
|
||||||
|
_item('text.extract'),
|
||||||
|
_item('text.anonymize', status: 'alpha'),
|
||||||
|
]);
|
||||||
|
expect(caps, {'text.extract', 'text.anonymize'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('required capabilities of an entry are NOT installable', () {
|
||||||
|
// The repro class behind "no store entry for '<provider>/<name>'":
|
||||||
|
// a capability that only appears as some entry's requirement
|
||||||
|
// must never be offered as an install target.
|
||||||
|
final caps = installableStoreCapabilities([
|
||||||
|
_item(
|
||||||
|
'doc.pipeline',
|
||||||
|
requires: ['example-provider/tool.summarize', 'llm.generate'],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
expect(caps, {'doc.pipeline'});
|
||||||
|
expect(caps.contains('example-provider/tool.summarize'), isFalse);
|
||||||
|
expect(caps.contains('llm.generate'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planned entries have nothing to download and are excluded', () {
|
||||||
|
final caps = installableStoreCapabilities([
|
||||||
|
_item('web.scrape', status: 'planned'),
|
||||||
|
]);
|
||||||
|
expect(caps, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('federated entries do not use the bundle-install path', () {
|
||||||
|
final caps = installableStoreCapabilities([
|
||||||
|
_item('mcp.files.read', kind: 'federated'),
|
||||||
|
]);
|
||||||
|
expect(caps, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty status (older hub) keeps the entry installable', () {
|
||||||
|
final caps = installableStoreCapabilities([_item('debug.echo', status: '')]);
|
||||||
|
expect(caps, {'debug.echo'});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue