neoagent 2.3.1-beta.89 → 2.3.1-beta.90
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.
- package/README.md +16 -7
- package/flutter_app/lib/features/location/location_service.dart +2 -4
- package/flutter_app/lib/main.dart +1 -0
- package/flutter_app/lib/main_app_shell.dart +11 -11
- package/flutter_app/lib/main_chat.dart +46 -42
- package/flutter_app/lib/main_controller.dart +1 -1
- package/flutter_app/lib/main_devices.dart +10 -1
- package/flutter_app/lib/main_integrations.dart +3 -3
- package/flutter_app/lib/main_spacing.dart +18 -0
- package/flutter_app/lib/main_theme.dart +9 -0
- package/flutter_app/lib/main_unified.dart +3 -3
- package/lib/manager.js +33 -0
- package/package.json +1 -1
- package/server/db/database.js +74 -16
- package/server/guest_agent.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +11977 -11964
- package/server/services/android/android_bootstrap_worker.js +18 -3
- package/server/services/android/controller.js +426 -324
- package/server/services/runtime/backends/local-vm.js +31 -9
package/README.md
CHANGED
|
@@ -9,28 +9,37 @@
|
|
|
9
9
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-a855f7?style=flat-square" alt="License"></a>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
-
<p align="center">
|
|
12
|
+
<p align="center">Self-hosted AI agent — runs as a system service, controls Android over ADB, connects to 15+ messaging platforms, all credentials on your server.</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="demo.gif" alt="NeoAgent demo" width="100%">
|
|
16
|
+
</p>
|
|
13
17
|
|
|
14
18
|
| | | | |
|
|
15
19
|
| --- | --- | --- | --- |
|
|
16
20
|
| <img alt="WebUI" src="https://github.com/user-attachments/assets/3c76d59a-b6e3-4698-929b-9c94741ccf1e" height="420"> | <img height="494" alt="Android" src="https://github.com/user-attachments/assets/e8a0af7a-6881-485d-ad52-f3bc6f2023ca"> | <img alt="Mobile Telegram" src="https://github.com/user-attachments/assets/1fd41a9b-5452-4aa4-9478-888c8ad7363a" height="420"> | <img height="494" alt="image" src="https://github.com/user-attachments/assets/d5a57282-0851-4902-9588-d8de4b82d45c"> |
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
- **Android control** — screenshot, observe UI, tap, swipe, type, launch apps, install APKs, `adb shell` — the agent operates Android, not just an app running on it
|
|
23
|
+
- **15+ messaging platforms** — Telegram, WhatsApp, Discord, Signal, Slack, Matrix, iMessage, IRC, LINE, Mattermost, Telnyx Voice
|
|
24
|
+
- **Integrations** — Google Workspace, Microsoft 365, Notion, Home Assistant, Trello, Spotify, Figma
|
|
25
|
+
- **Browser + shell** — VM-isolated server-side browser automation, full PTY terminal
|
|
26
|
+
- **Runs locally** — Ollama support, no API key required; credentials stay in `~/.neoagent/.env`, never in the client
|
|
19
27
|
|
|
20
28
|
## Install
|
|
21
29
|
|
|
30
|
+
Requires Node.js 20+ and QEMU — see [getting started](docs/getting-started.md) for details.
|
|
31
|
+
|
|
22
32
|
```bash
|
|
23
33
|
npm install -g neoagent
|
|
24
34
|
neoagent install
|
|
25
|
-
|
|
26
|
-
neoagent migrate
|
|
27
35
|
```
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
Runs as a `launchd` user service on macOS and `systemd --user` on Linux.
|
|
38
|
+
|
|
39
|
+
## Manage
|
|
30
40
|
|
|
31
41
|
```bash
|
|
32
42
|
neoagent status
|
|
33
|
-
neoagent channel beta
|
|
34
43
|
neoagent update
|
|
35
44
|
neoagent fix
|
|
36
45
|
neoagent logs
|
|
@@ -38,7 +47,7 @@ neoagent logs
|
|
|
38
47
|
|
|
39
48
|
## Links
|
|
40
49
|
|
|
41
|
-
[Docs](https://neolabs-systems.github.io/NeoAgent/docs/) | [
|
|
50
|
+
[Docs](https://neolabs-systems.github.io/NeoAgent/docs/) | [Capabilities](docs/capabilities.md) | [Why NeoAgent](docs/why-neoagent.md) | [Issues](https://github.com/NeoLabs-Systems/NeoAgent/issues)
|
|
42
51
|
|
|
43
52
|
---
|
|
44
53
|
|
|
@@ -2,8 +2,6 @@ import 'dart:convert';
|
|
|
2
2
|
import 'package:flutter/material.dart';
|
|
3
3
|
import 'package:geolocator/geolocator.dart';
|
|
4
4
|
import 'package:http/http.dart' as http;
|
|
5
|
-
import 'package:shared_preferences/shared_preferences.dart';
|
|
6
|
-
|
|
7
5
|
class LocationService {
|
|
8
6
|
static final LocationService _instance = LocationService._internal();
|
|
9
7
|
factory LocationService() => _instance;
|
|
@@ -78,8 +76,8 @@ class LocationService {
|
|
|
78
76
|
void _trackLoop(String backendUrl, String token) async {
|
|
79
77
|
while (_isTracking) {
|
|
80
78
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
await Geolocator.getCurrentPosition(
|
|
80
|
+
locationSettings: const LocationSettings(accuracy: LocationAccuracy.low));
|
|
83
81
|
|
|
84
82
|
// Here we would normally fetch active geofences from local DB or backend
|
|
85
83
|
// and calculate the distance. If inside a geofence, we trigger:
|
|
@@ -47,6 +47,7 @@ import 'features/location/location_service.dart';
|
|
|
47
47
|
import 'features/notifications/notification_interceptor.dart';
|
|
48
48
|
import 'features/onboarding/onboarding_shell.dart';
|
|
49
49
|
|
|
50
|
+
part 'main_spacing.dart';
|
|
50
51
|
part 'main_theme.dart';
|
|
51
52
|
part 'main_app_shell.dart';
|
|
52
53
|
part 'main_launcher.dart';
|
|
@@ -360,11 +360,11 @@ class _AuthViewState extends State<AuthView> {
|
|
|
360
360
|
version: QrVersions.auto,
|
|
361
361
|
eyeStyle: const QrEyeStyle(
|
|
362
362
|
eyeShape: QrEyeShape.square,
|
|
363
|
-
color:
|
|
363
|
+
color: _qrDarkColor,
|
|
364
364
|
),
|
|
365
365
|
dataModuleStyle: const QrDataModuleStyle(
|
|
366
366
|
dataModuleShape: QrDataModuleShape.square,
|
|
367
|
-
color:
|
|
367
|
+
color: _qrDarkColor,
|
|
368
368
|
),
|
|
369
369
|
)
|
|
370
370
|
: controller.isPreparingQrLogin
|
|
@@ -818,13 +818,13 @@ class _AuthViewState extends State<AuthView> {
|
|
|
818
818
|
version: QrVersions.auto,
|
|
819
819
|
eyeStyle: const QrEyeStyle(
|
|
820
820
|
eyeShape: QrEyeShape.square,
|
|
821
|
-
color:
|
|
821
|
+
color: _qrDarkColor,
|
|
822
822
|
),
|
|
823
823
|
dataModuleStyle:
|
|
824
824
|
const QrDataModuleStyle(
|
|
825
825
|
dataModuleShape:
|
|
826
826
|
QrDataModuleShape.square,
|
|
827
|
-
color:
|
|
827
|
+
color: _qrDarkColor,
|
|
828
828
|
),
|
|
829
829
|
)
|
|
830
830
|
: controller.isPreparingQrLogin
|
|
@@ -852,14 +852,14 @@ class _AuthViewState extends State<AuthView> {
|
|
|
852
852
|
style: FilledButton.styleFrom(
|
|
853
853
|
minimumSize: const Size.fromHeight(56),
|
|
854
854
|
backgroundColor: Colors.white,
|
|
855
|
-
foregroundColor:
|
|
855
|
+
foregroundColor: _qrDarkColor,
|
|
856
856
|
),
|
|
857
857
|
icon: controller.isPreparingQrLogin
|
|
858
858
|
? const SizedBox.square(
|
|
859
859
|
dimension: 16,
|
|
860
860
|
child: CircularProgressIndicator(
|
|
861
861
|
strokeWidth: 2,
|
|
862
|
-
color:
|
|
862
|
+
color: _qrDarkColor,
|
|
863
863
|
),
|
|
864
864
|
)
|
|
865
865
|
: const Icon(Icons.qr_code_2_rounded),
|
|
@@ -972,7 +972,7 @@ class _AuthViewState extends State<AuthView> {
|
|
|
972
972
|
),
|
|
973
973
|
child: Padding(
|
|
974
974
|
padding: EdgeInsets.all(
|
|
975
|
-
viewportConstraints.maxWidth <
|
|
975
|
+
viewportConstraints.maxWidth < AppBreakpoints.mobile ? 14 : 24,
|
|
976
976
|
),
|
|
977
977
|
child: Center(
|
|
978
978
|
child: ConstrainedBox(
|
|
@@ -988,10 +988,10 @@ class _AuthViewState extends State<AuthView> {
|
|
|
988
988
|
fillColor: _glassFill,
|
|
989
989
|
child: Padding(
|
|
990
990
|
padding: EdgeInsets.fromLTRB(
|
|
991
|
-
viewportConstraints.maxWidth <
|
|
992
|
-
viewportConstraints.maxWidth <
|
|
993
|
-
viewportConstraints.maxWidth <
|
|
994
|
-
viewportConstraints.maxWidth <
|
|
991
|
+
viewportConstraints.maxWidth < AppBreakpoints.mobile ? 18 : 34,
|
|
992
|
+
viewportConstraints.maxWidth < AppBreakpoints.mobile ? 20 : 30,
|
|
993
|
+
viewportConstraints.maxWidth < AppBreakpoints.mobile ? 18 : 34,
|
|
994
|
+
viewportConstraints.maxWidth < AppBreakpoints.mobile ? 20 : 30,
|
|
995
995
|
),
|
|
996
996
|
child: LayoutBuilder(
|
|
997
997
|
builder: (context, panelConstraints) {
|
|
@@ -339,49 +339,52 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
339
339
|
child: Icon(Icons.call_rounded, color: Colors.white),
|
|
340
340
|
),
|
|
341
341
|
const SizedBox(width: 8),
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
_isSendingChatMessage = true;
|
|
354
|
-
});
|
|
355
|
-
_composerController.clear();
|
|
356
|
-
final outgoingAttachments =
|
|
357
|
-
_pendingSharedAttachments;
|
|
358
|
-
_clearSharedPayload();
|
|
359
|
-
try {
|
|
360
|
-
await controller.sendMessage(
|
|
361
|
-
task,
|
|
362
|
-
sharedAttachments: outgoingAttachments,
|
|
363
|
-
);
|
|
364
|
-
} finally {
|
|
365
|
-
if (mounted) {
|
|
366
|
-
setState(() {
|
|
367
|
-
_isSendingChatMessage = false;
|
|
368
|
-
});
|
|
342
|
+
Tooltip(
|
|
343
|
+
message: 'Send (⌘↩)',
|
|
344
|
+
child: FilledButton(
|
|
345
|
+
onPressed: _isSendingChatMessage
|
|
346
|
+
? null
|
|
347
|
+
: () async {
|
|
348
|
+
final task = _composerController.text;
|
|
349
|
+
if ((task.trim().isEmpty &&
|
|
350
|
+
_pendingSharedAttachments.isEmpty) ||
|
|
351
|
+
_isSendingChatMessage) {
|
|
352
|
+
return;
|
|
369
353
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
354
|
+
setState(() {
|
|
355
|
+
_isSendingChatMessage = true;
|
|
356
|
+
});
|
|
357
|
+
_composerController.clear();
|
|
358
|
+
final outgoingAttachments =
|
|
359
|
+
_pendingSharedAttachments;
|
|
360
|
+
_clearSharedPayload();
|
|
361
|
+
try {
|
|
362
|
+
await controller.sendMessage(
|
|
363
|
+
task,
|
|
364
|
+
sharedAttachments: outgoingAttachments,
|
|
365
|
+
);
|
|
366
|
+
} finally {
|
|
367
|
+
if (mounted) {
|
|
368
|
+
setState(() {
|
|
369
|
+
_isSendingChatMessage = false;
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
style: FilledButton.styleFrom(
|
|
375
|
+
minimumSize: const Size(46, 42),
|
|
376
|
+
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
377
|
+
backgroundColor: _accent,
|
|
378
|
+
shape: RoundedRectangleBorder(
|
|
379
|
+
borderRadius: BorderRadius.circular(10),
|
|
380
|
+
),
|
|
381
|
+
),
|
|
382
|
+
child: Icon(
|
|
383
|
+
controller.hasLiveRun
|
|
384
|
+
? Icons.alt_route_rounded
|
|
385
|
+
: Icons.north_east_rounded,
|
|
386
|
+
color: Colors.white,
|
|
378
387
|
),
|
|
379
|
-
),
|
|
380
|
-
child: Icon(
|
|
381
|
-
controller.hasLiveRun
|
|
382
|
-
? Icons.alt_route_rounded
|
|
383
|
-
: Icons.north_east_rounded,
|
|
384
|
-
color: Colors.white,
|
|
385
388
|
),
|
|
386
389
|
),
|
|
387
390
|
],
|
|
@@ -1009,7 +1012,7 @@ class _MessagingMetricCard extends StatelessWidget {
|
|
|
1009
1012
|
value,
|
|
1010
1013
|
style: TextStyle(
|
|
1011
1014
|
color: _textPrimary,
|
|
1012
|
-
fontSize:
|
|
1015
|
+
fontSize: 22,
|
|
1013
1016
|
fontWeight: FontWeight.w800,
|
|
1014
1017
|
),
|
|
1015
1018
|
),
|
|
@@ -2244,6 +2247,7 @@ Future<_MessagingRuleSelection?> _showMessagingAccessRulePicker(
|
|
|
2244
2247
|
required MessagingPlatformDescriptor platform,
|
|
2245
2248
|
required MessagingAccessCatalog catalog,
|
|
2246
2249
|
}) async {
|
|
2250
|
+
// Use BottomSheet for contextual actions, AlertDialog for confirmations.
|
|
2247
2251
|
return showModalBottomSheet<_MessagingRuleSelection>(
|
|
2248
2252
|
context: context,
|
|
2249
2253
|
isScrollControlled: true,
|
|
@@ -1080,6 +1080,7 @@ class _AndroidNavDock extends StatelessWidget {
|
|
|
1080
1080
|
final disabled =
|
|
1081
1081
|
busy || (!androidOnline && entry.key.startsWith('android_'));
|
|
1082
1082
|
return IconButton.filledTonal(
|
|
1083
|
+
tooltip: entry.key.replaceAll('_', ' '),
|
|
1083
1084
|
onPressed: disabled ? null : () => onAction(entry.key),
|
|
1084
1085
|
icon: Icon(entry.value),
|
|
1085
1086
|
);
|
|
@@ -1132,6 +1133,7 @@ class _SurfaceSwitcher extends StatelessWidget {
|
|
|
1132
1133
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
1133
1134
|
children: <Widget>[
|
|
1134
1135
|
IconButton.filledTonal(
|
|
1136
|
+
tooltip: 'Previous surface',
|
|
1135
1137
|
onPressed: onPrevious,
|
|
1136
1138
|
icon: Icon(Icons.arrow_back_ios_new_rounded),
|
|
1137
1139
|
),
|
|
@@ -1139,6 +1141,7 @@ class _SurfaceSwitcher extends StatelessWidget {
|
|
|
1139
1141
|
Flexible(child: labelColumn),
|
|
1140
1142
|
const SizedBox(width: 14),
|
|
1141
1143
|
IconButton.filledTonal(
|
|
1144
|
+
tooltip: 'Next surface',
|
|
1142
1145
|
onPressed: onNext,
|
|
1143
1146
|
icon: Icon(Icons.arrow_forward_ios_rounded),
|
|
1144
1147
|
),
|
|
@@ -1152,6 +1155,7 @@ class _SurfaceSwitcher extends StatelessWidget {
|
|
|
1152
1155
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
1153
1156
|
children: <Widget>[
|
|
1154
1157
|
IconButton.filledTonal(
|
|
1158
|
+
tooltip: 'Previous surface',
|
|
1155
1159
|
onPressed: onPrevious,
|
|
1156
1160
|
icon: Icon(Icons.arrow_back_ios_new_rounded),
|
|
1157
1161
|
),
|
|
@@ -1159,6 +1163,7 @@ class _SurfaceSwitcher extends StatelessWidget {
|
|
|
1159
1163
|
labelColumn,
|
|
1160
1164
|
const SizedBox(width: 14),
|
|
1161
1165
|
IconButton.filledTonal(
|
|
1166
|
+
tooltip: 'Next surface',
|
|
1162
1167
|
onPressed: onNext,
|
|
1163
1168
|
icon: Icon(Icons.arrow_forward_ios_rounded),
|
|
1164
1169
|
),
|
|
@@ -1386,7 +1391,10 @@ class _InteractiveSurfacePreviewState
|
|
|
1386
1391
|
onPressed: widget.onWakeRequested,
|
|
1387
1392
|
);
|
|
1388
1393
|
}
|
|
1389
|
-
return
|
|
1394
|
+
return Semantics(
|
|
1395
|
+
button: true,
|
|
1396
|
+
label: 'Device surface preview — tap to interact, swipe to scroll',
|
|
1397
|
+
child: GestureDetector(
|
|
1390
1398
|
onTapUp: widget.busy
|
|
1391
1399
|
? null
|
|
1392
1400
|
: (details) async {
|
|
@@ -1462,6 +1470,7 @@ class _InteractiveSurfacePreviewState
|
|
|
1462
1470
|
const Center(child: CircularProgressIndicator()),
|
|
1463
1471
|
],
|
|
1464
1472
|
),
|
|
1473
|
+
),
|
|
1465
1474
|
);
|
|
1466
1475
|
},
|
|
1467
1476
|
),
|
|
@@ -326,9 +326,9 @@ Future<void> _showTrelloSetupDialog(
|
|
|
326
326
|
Container(
|
|
327
327
|
padding: const EdgeInsets.all(8),
|
|
328
328
|
decoration: BoxDecoration(
|
|
329
|
-
color: _danger.
|
|
329
|
+
color: _danger.withValues(alpha: 0.1),
|
|
330
330
|
borderRadius: BorderRadius.circular(4),
|
|
331
|
-
border: Border.all(color: _danger.
|
|
331
|
+
border: Border.all(color: _danger.withValues(alpha: 0.3)),
|
|
332
332
|
),
|
|
333
333
|
child: Text(
|
|
334
334
|
formError,
|
|
@@ -543,7 +543,7 @@ class _TrelloStatusItem extends StatelessWidget {
|
|
|
543
543
|
color: _bgSecondary,
|
|
544
544
|
borderRadius: BorderRadius.circular(8),
|
|
545
545
|
border: Border.all(
|
|
546
|
-
color: isConnected ? _success.
|
|
546
|
+
color: isConnected ? _success.withValues(alpha: 0.3) : _border,
|
|
547
547
|
),
|
|
548
548
|
),
|
|
549
549
|
child: Row(
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
part of 'main.dart';
|
|
2
|
+
|
|
3
|
+
abstract class AppSpacing {
|
|
4
|
+
static const double xs = 8.0;
|
|
5
|
+
static const double sm = 12.0;
|
|
6
|
+
static const double md = 16.0;
|
|
7
|
+
static const double lg = 24.0;
|
|
8
|
+
static const double xl = 32.0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
abstract class AppBreakpoints {
|
|
12
|
+
static const double mobile = 480.0;
|
|
13
|
+
static const double tablet = 960.0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// Deep dark color used for QR code modules rendered on a white background.
|
|
17
|
+
/// Matches the dark theme's primary background hue for brand consistency.
|
|
18
|
+
const Color _qrDarkColor = Color(0xFF04111D);
|
|
@@ -109,6 +109,7 @@ ThemeData _buildNeoAgentTheme(NeoAgentPalette palette, Brightness brightness) {
|
|
|
109
109
|
);
|
|
110
110
|
|
|
111
111
|
return base.copyWith(
|
|
112
|
+
focusColor: palette.accent.withValues(alpha: 0.2),
|
|
112
113
|
scaffoldBackgroundColor: palette.bgPrimary,
|
|
113
114
|
colorScheme: base.colorScheme.copyWith(
|
|
114
115
|
primary: palette.accent,
|
|
@@ -229,6 +230,14 @@ ThemeData _buildNeoAgentTheme(NeoAgentPalette palette, Brightness brightness) {
|
|
|
229
230
|
),
|
|
230
231
|
),
|
|
231
232
|
),
|
|
233
|
+
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
234
|
+
style: ElevatedButton.styleFrom(
|
|
235
|
+
padding: const EdgeInsets.symmetric(
|
|
236
|
+
horizontal: AppSpacing.md,
|
|
237
|
+
vertical: AppSpacing.sm,
|
|
238
|
+
),
|
|
239
|
+
),
|
|
240
|
+
),
|
|
232
241
|
appBarTheme: AppBarTheme(
|
|
233
242
|
backgroundColor: Colors.transparent,
|
|
234
243
|
surfaceTintColor: Colors.transparent,
|
|
@@ -90,7 +90,7 @@ class _ToolsPanelState extends State<ToolsPanel>
|
|
|
90
90
|
),
|
|
91
91
|
child: TabBar(
|
|
92
92
|
controller: _tabController,
|
|
93
|
-
dividerColor:
|
|
93
|
+
dividerColor: _border,
|
|
94
94
|
indicatorSize: TabBarIndicatorSize.tab,
|
|
95
95
|
labelStyle: const TextStyle(fontWeight: FontWeight.w700),
|
|
96
96
|
tabs: <Widget>[
|
|
@@ -190,7 +190,7 @@ class _RunsAndLogsPanelState extends State<RunsAndLogsPanel>
|
|
|
190
190
|
),
|
|
191
191
|
child: TabBar(
|
|
192
192
|
controller: _tabController,
|
|
193
|
-
dividerColor:
|
|
193
|
+
dividerColor: _border,
|
|
194
194
|
indicatorSize: TabBarIndicatorSize.tab,
|
|
195
195
|
labelStyle: const TextStyle(fontWeight: FontWeight.w700),
|
|
196
196
|
tabs: <Widget>[
|
|
@@ -251,7 +251,7 @@ class _SettingsWorkspacePanelState extends State<SettingsWorkspacePanel> {
|
|
|
251
251
|
|
|
252
252
|
@override
|
|
253
253
|
Widget build(BuildContext context) {
|
|
254
|
-
final compact = MediaQuery.sizeOf(context).width <
|
|
254
|
+
final compact = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
|
|
255
255
|
return Padding(
|
|
256
256
|
padding: _pagePadding(context),
|
|
257
257
|
child: Column(
|
package/lib/manager.js
CHANGED
|
@@ -288,6 +288,38 @@ function ensureLogDir() {
|
|
|
288
288
|
ensureRuntimeDirs();
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
function pruneOldRuntimeBackups(backupsDir, keepLatest = 3) {
|
|
292
|
+
if (!fs.existsSync(backupsDir) || keepLatest < 0) return;
|
|
293
|
+
|
|
294
|
+
const backupDirs = fs
|
|
295
|
+
.readdirSync(backupsDir, { withFileTypes: true })
|
|
296
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('pre-update-'))
|
|
297
|
+
.map((entry) => {
|
|
298
|
+
const fullPath = path.join(backupsDir, entry.name);
|
|
299
|
+
let mtimeMs = 0;
|
|
300
|
+
try {
|
|
301
|
+
mtimeMs = fs.statSync(fullPath).mtimeMs;
|
|
302
|
+
} catch {
|
|
303
|
+
// Skip entries that disappear or cannot be statted.
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
return { name: entry.name, fullPath, mtimeMs };
|
|
307
|
+
})
|
|
308
|
+
.filter(Boolean)
|
|
309
|
+
.sort((a, b) => {
|
|
310
|
+
if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs;
|
|
311
|
+
return b.name.localeCompare(a.name);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
for (const backup of backupDirs.slice(keepLatest)) {
|
|
315
|
+
try {
|
|
316
|
+
fs.rmSync(backup.fullPath, { recursive: true, force: true });
|
|
317
|
+
} catch {
|
|
318
|
+
// Best-effort cleanup only.
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
291
323
|
function backupRuntimeData() {
|
|
292
324
|
const backupsDir = path.join(RUNTIME_HOME, 'backups');
|
|
293
325
|
fs.mkdirSync(backupsDir, { recursive: true });
|
|
@@ -297,6 +329,7 @@ function backupRuntimeData() {
|
|
|
297
329
|
|
|
298
330
|
if (fs.existsSync(ENV_FILE)) fs.copyFileSync(ENV_FILE, path.join(target, '.env'));
|
|
299
331
|
if (fs.existsSync(DATA_DIR)) fs.cpSync(DATA_DIR, path.join(target, 'data'), { recursive: true, force: false, errorOnExist: false });
|
|
332
|
+
pruneOldRuntimeBackups(backupsDir, 3);
|
|
300
333
|
}
|
|
301
334
|
|
|
302
335
|
function killByPort(port) {
|
package/package.json
CHANGED
package/server/db/database.js
CHANGED
|
@@ -50,6 +50,43 @@ function initializeDatabase(db, dbPath) {
|
|
|
50
50
|
return db;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function sleepSync(ms) {
|
|
54
|
+
if (ms <= 0) return;
|
|
55
|
+
const buffer = new SharedArrayBuffer(4);
|
|
56
|
+
const view = new Int32Array(buffer);
|
|
57
|
+
Atomics.wait(view, 0, 0, ms);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isSqliteBusyError(error) {
|
|
61
|
+
return Boolean(
|
|
62
|
+
error &&
|
|
63
|
+
(
|
|
64
|
+
error.code === 'SQLITE_BUSY' ||
|
|
65
|
+
error.code === 'SQLITE_LOCKED' ||
|
|
66
|
+
/database is locked|database table is locked/i.test(error.message || '')
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runWithBusyRetry(action, { attempts = 5, delayMs = 50, label = 'SQLite operation' } = {}) {
|
|
72
|
+
let lastError = null;
|
|
73
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
74
|
+
try {
|
|
75
|
+
return action();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
lastError = error;
|
|
78
|
+
if (!isSqliteBusyError(error) || attempt === attempts) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
console.warn(
|
|
82
|
+
`[Database] ${label} hit a busy lock on attempt ${attempt}/${attempts}; retrying in ${delayMs}ms.`,
|
|
83
|
+
);
|
|
84
|
+
sleepSync(delayMs);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw lastError;
|
|
88
|
+
}
|
|
89
|
+
|
|
53
90
|
let db = new Database(DB_PATH);
|
|
54
91
|
db = initializeDatabase(db, DB_PATH);
|
|
55
92
|
|
|
@@ -1047,7 +1084,19 @@ function backfillAgentIds() {
|
|
|
1047
1084
|
}
|
|
1048
1085
|
}
|
|
1049
1086
|
});
|
|
1050
|
-
|
|
1087
|
+
|
|
1088
|
+
try {
|
|
1089
|
+
runWithBusyRetry(
|
|
1090
|
+
() => tx(),
|
|
1091
|
+
{ attempts: 8, label: 'backfillAgentIds' },
|
|
1092
|
+
);
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
if (isSqliteBusyError(error)) {
|
|
1095
|
+
console.warn('[Database] Skipping backfillAgentIds because the database stayed locked.');
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
throw error;
|
|
1099
|
+
}
|
|
1051
1100
|
}
|
|
1052
1101
|
|
|
1053
1102
|
function backfillAgentPolicies() {
|
|
@@ -1059,21 +1108,30 @@ function backfillAgentPolicies() {
|
|
|
1059
1108
|
return;
|
|
1060
1109
|
}
|
|
1061
1110
|
try {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1111
|
+
runWithBusyRetry(
|
|
1112
|
+
() => {
|
|
1113
|
+
db.prepare(
|
|
1114
|
+
`UPDATE agents
|
|
1115
|
+
SET can_delegate = COALESCE(can_delegate, 1),
|
|
1116
|
+
can_be_delegated_to = COALESCE(can_be_delegated_to, 0),
|
|
1117
|
+
delegate_targets_json = COALESCE(delegate_targets_json, '[]')
|
|
1118
|
+
WHERE slug = 'main'`
|
|
1119
|
+
).run();
|
|
1120
|
+
db.prepare(
|
|
1121
|
+
`UPDATE agents
|
|
1122
|
+
SET can_delegate = COALESCE(can_delegate, 0),
|
|
1123
|
+
can_be_delegated_to = COALESCE(can_be_delegated_to, 1),
|
|
1124
|
+
delegate_targets_json = COALESCE(delegate_targets_json, '[]')
|
|
1125
|
+
WHERE slug != 'main'`
|
|
1126
|
+
).run();
|
|
1127
|
+
},
|
|
1128
|
+
{ attempts: 8, label: 'backfillAgentPolicies' },
|
|
1129
|
+
);
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
if (isSqliteBusyError(error)) {
|
|
1132
|
+
console.warn('[Database] Skipping backfillAgentPolicies because the database stayed locked.');
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1077
1135
|
// Keep startup resilient for partially migrated databases.
|
|
1078
1136
|
}
|
|
1079
1137
|
}
|
package/server/guest_agent.js
CHANGED
|
@@ -150,6 +150,7 @@ app.post('/files/read', async (req, res) => {
|
|
|
150
150
|
}
|
|
151
151
|
const realTarget = resolveReadablePath(filePath);
|
|
152
152
|
if (!realTarget) {
|
|
153
|
+
console.warn('[GuestAgent] files/read rejected path', { requestedPath: filePath });
|
|
153
154
|
return { error: 'path is outside guest-agent readable roots' };
|
|
154
155
|
}
|
|
155
156
|
const data = fs.readFileSync(realTarget);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
60e7dbe66a3ba1c874f738f6f995f811
|
|
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"42d3d75a56efe1a2e9902f52dc8006099c45d9
|
|
|
37
37
|
|
|
38
38
|
_flutter.loader.load({
|
|
39
39
|
serviceWorkerSettings: {
|
|
40
|
-
serviceWorkerVersion: "
|
|
40
|
+
serviceWorkerVersion: "3267367784" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
|
|
41
41
|
}
|
|
42
42
|
});
|