Coverage for core / platform / app_manifest.py: 100.0%
52 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2App Manifest — Universal schema for all HART OS application types.
4PRINCIPLE: Each process in HART OS is an agent working towards a larger
5cause encoded in a goal with true meaning and rationality. Even when
6humans are irrational, the being is the light.
8Every app in HART OS — Nunba panel, system panel, desktop app (RustDesk),
9agent, MCP server, channel plugin, extension — is described by a single
10AppManifest dataclass. This is the unifying abstraction.
12A SERVICE is an AGENT. An AGENT is a SERVICE. The distinction is only
13how it's invoked — both have a purpose (why they exist), both contribute
14to a goal, both are accountable to the hive's constitutional governance.
16Generalizes:
17- shell_manifest.py: PANEL_MANIFEST, SYSTEM_PANELS, DYNAMIC_PANELS
18- service_manager.py: EngineInfo for native desktop apps
19- mcp_integration.py: MCP server descriptions
20- plugin_system.py: PluginMetadata
22The `entry` dict is type-specific:
23 nunba_panel: {'route': '/social'}
24 system_panel: {'api': '/api/shell/audio'}
25 desktop_app: {'exec': 'rustdesk', 'bridge': 'rustdesk_bridge'}
26 service: {'http': 'http://localhost:8080'}
27 agent: {'prompt_id': '123', 'flow_id': '0'}
28 mcp_server: {'mcp': 'filesystem'}
29 channel: {'adapter': 'discord'}
30 extension: {'module': 'extensions.my_ext'}
31"""
33from dataclasses import dataclass, field
34from enum import Enum
35from typing import Any, Dict, List, Optional, Tuple
38class AppType(Enum):
39 """All application types in HART OS."""
40 NUNBA_PANEL = 'nunba_panel' # Nunba SPA iframe panel
41 SYSTEM_PANEL = 'system_panel' # Native Python-rendered panel
42 DYNAMIC_PANEL = 'dynamic_panel' # Context-opened panel
43 DESKTOP_APP = 'desktop_app' # External native binary (Rust/C++)
44 SERVICE = 'service' # Background service (llama.cpp, etc.)
45 AGENT = 'agent' # AI agent
46 MCP_SERVER = 'mcp_server' # Model Context Protocol server
47 CHANNEL = 'channel' # Channel adapter (Discord, Telegram)
48 EXTENSION = 'extension' # Platform extension/plugin
51@dataclass
52class AppManifest:
53 """Universal manifest for any HART OS application.
55 Every app type uses the same schema. The `type` field determines
56 how the shell renders it and how lifecycle management works.
57 """
58 id: str # Unique: 'feed', 'rustdesk', 'mcp_filesystem'
59 name: str # Display name: 'Feed', 'RustDesk'
60 version: str # Semver or 'auto' for detected binaries
61 type: str # AppType value string
62 icon: str = 'extension' # Material icon name or file path
63 entry: Dict[str, Any] = field(default_factory=dict) # Type-specific launch info
64 group: str = '' # Start menu group: 'Discover', 'System'
65 default_size: Tuple[int, int] = (800, 600) # Default window size
66 permissions: List[str] = field(default_factory=list) # ['audio', 'display', 'network']
67 apis: List[str] = field(default_factory=list) # API endpoints used
68 config_schema: Dict[str, Any] = field(default_factory=dict) # Settings schema
69 dependencies: List[str] = field(default_factory=list) # Other app IDs required
70 platforms: List[str] = field(default_factory=lambda: ['all']) # ['linux', 'windows']
71 singleton: bool = True # Only one instance at a time?
72 auto_start: bool = False # Start with OS?
73 tags: List[str] = field(default_factory=list) # Search tags
74 description: str = '' # Short description (WHAT it does)
75 purpose: str = '' # WHY it exists (true meaning)
76 goal_type: str = '' # Goal type for dispatch (links to goal_manager)
77 ai_capabilities: List[Dict[str, Any]] = field(default_factory=list) # AICapability dicts
79 def to_dict(self) -> Dict[str, Any]:
80 """Serialize to dict (for API responses, JSON storage)."""
81 return {
82 'id': self.id,
83 'name': self.name,
84 'version': self.version,
85 'type': self.type,
86 'icon': self.icon,
87 'entry': self.entry,
88 'group': self.group,
89 'default_size': list(self.default_size),
90 'permissions': self.permissions,
91 'apis': self.apis,
92 'config_schema': self.config_schema,
93 'dependencies': self.dependencies,
94 'platforms': self.platforms,
95 'singleton': self.singleton,
96 'auto_start': self.auto_start,
97 'tags': self.tags,
98 'description': self.description,
99 'purpose': self.purpose,
100 'goal_type': self.goal_type,
101 'ai_capabilities': self.ai_capabilities,
102 }
104 @classmethod
105 def from_dict(cls, data: Dict[str, Any]) -> 'AppManifest':
106 """Deserialize from dict."""
107 d = dict(data)
108 if 'default_size' in d:
109 d['default_size'] = tuple(d['default_size'])
110 return cls(**{k: v for k, v in d.items()
111 if k in cls.__dataclass_fields__})
113 @classmethod
114 def from_panel_manifest(cls, panel_id: str,
115 panel: Dict[str, Any]) -> 'AppManifest':
116 """Convert a shell_manifest.py PANEL_MANIFEST entry to AppManifest.
118 Backward compatibility bridge for existing panel definitions.
119 """
120 return cls(
121 id=panel_id,
122 name=panel.get('title', panel_id),
123 version='1.0.0',
124 type=AppType.NUNBA_PANEL.value,
125 icon=panel.get('icon', 'extension'),
126 entry={'route': panel.get('route', '')},
127 group=panel.get('group', ''),
128 default_size=tuple(panel.get('default_size', [800, 600])),
129 apis=panel.get('apis', []),
130 tags=panel.get('tags', []),
131 )
133 @classmethod
134 def from_system_panel(cls, panel_id: str,
135 panel: Dict[str, Any]) -> 'AppManifest':
136 """Convert a shell_manifest.py SYSTEM_PANELS entry to AppManifest."""
137 return cls(
138 id=panel_id,
139 name=panel.get('title', panel_id),
140 version='1.0.0',
141 type=AppType.SYSTEM_PANEL.value,
142 icon=panel.get('icon', 'settings'),
143 entry={'loader': panel.get('loader', '')},
144 group=panel.get('group', 'System'),
145 default_size=tuple(panel.get('default_size', [700, 500])),
146 apis=panel.get('apis', []),
147 tags=panel.get('tags', []),
148 )
150 def matches_search(self, query: str) -> bool:
151 """Check if this manifest matches a search query (case-insensitive)."""
152 q = query.lower()
153 return (q in self.id.lower()
154 or q in self.name.lower()
155 or q in self.description.lower()
156 or q in self.group.lower()
157 or any(q in tag.lower() for tag in self.tags))