@swarmclawai/swarmclaw 1.9.2 → 1.9.4

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.
Files changed (32) hide show
  1. package/README.md +23 -3
  2. package/electron-dist/main.js +218 -0
  3. package/package.json +2 -2
  4. package/src/app/api/extensions/managed-resources/route.test.ts +117 -0
  5. package/src/app/api/extensions/managed-resources/route.ts +116 -0
  6. package/src/app/api/gateways/[id]/environments/[environmentId]/route.ts +16 -0
  7. package/src/app/api/gateways/[id]/environments/route.ts +13 -0
  8. package/src/app/api/gateways/topology-route.test.ts +30 -0
  9. package/src/app/api/tasks/task-workspace-route.test.ts +4 -0
  10. package/src/cli/index.js +4 -0
  11. package/src/cli/spec.js +4 -0
  12. package/src/components/providers/provider-list.tsx +34 -1
  13. package/src/components/tasks/task-sheet.tsx +50 -0
  14. package/src/features/gateways/queries.ts +3 -0
  15. package/src/lib/server/extension-managed-resources.test.ts +159 -0
  16. package/src/lib/server/extension-managed-resources.ts +905 -0
  17. package/src/lib/server/extensions.ts +113 -2
  18. package/src/lib/server/gateways/gateway-profile-service.ts +2 -0
  19. package/src/lib/server/gateways/gateway-topology.test.ts +59 -3
  20. package/src/lib/server/gateways/gateway-topology.ts +129 -3
  21. package/src/lib/server/operations/operation-pulse.test.ts +29 -0
  22. package/src/lib/server/operations/operation-pulse.ts +9 -0
  23. package/src/lib/server/session-tools/extension-creator.ts +50 -0
  24. package/src/lib/server/tasks/task-execution-workspace.test.ts +14 -0
  25. package/src/lib/server/tasks/task-execution-workspace.ts +133 -6
  26. package/src/types/agent.ts +2 -0
  27. package/src/types/app-settings.ts +8 -0
  28. package/src/types/extension.ts +132 -0
  29. package/src/types/misc.ts +31 -0
  30. package/src/types/schedule.ts +3 -0
  31. package/src/types/task.ts +30 -0
  32. package/src/views/settings/extension-manager.tsx +157 -1
@@ -475,10 +475,14 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
475
475
  <div className="text-[12px] font-800 text-text">Gateway fleet topology</div>
476
476
  <div className="mt-1 text-[11px] text-text-3/70">
477
477
  {gatewayFleetTopology.totals.connectedGatewayCount}/{gatewayFleetTopology.totals.gatewayCount} gateways connected ·{' '}
478
- {gatewayFleetTopology.totals.connectedNodeCount}/{gatewayFleetTopology.totals.nodeCount} nodes connected
478
+ {gatewayFleetTopology.totals.connectedNodeCount}/{gatewayFleetTopology.totals.nodeCount} nodes connected ·{' '}
479
+ {gatewayFleetTopology.totals.availableEnvironmentCount || 0}/{gatewayFleetTopology.totals.environmentCount || 0} environments available
479
480
  </div>
480
481
  </div>
481
482
  <div className="flex flex-wrap gap-2 text-[11px] text-text-3/70">
483
+ <span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2.5 py-1">
484
+ {gatewayFleetTopology.totals.environmentCount || 0} environments
485
+ </span>
482
486
  <span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2.5 py-1">
483
487
  {gatewayFleetTopology.totals.sessionCount || 0} sessions
484
488
  </span>
@@ -536,6 +540,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
536
540
  const topologyErrors = topology?.errors || []
537
541
  const pendingPairings = (stats?.pendingNodePairings || 0) + (stats?.pendingDevicePairings || 0)
538
542
  const topologyErrorCount = topologyErrors.length || stats?.lastTopologyErrorCount || 0
543
+ const environments = topology?.environments || []
539
544
  return (
540
545
  <div
541
546
  key={gateway.id}
@@ -602,6 +607,12 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
602
607
  {stats?.connectedNodeCount ?? 0}/{stats?.nodeCount ?? 0} nodes · {stats?.pairedDeviceCount ?? 0} devices
603
608
  </div>
604
609
  </div>
610
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
611
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Environments</div>
612
+ <div className="mt-1 text-text-2">
613
+ {stats?.availableEnvironmentCount ?? 0}/{stats?.environmentCount ?? 0} available
614
+ </div>
615
+ </div>
605
616
  <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
606
617
  <div className="uppercase tracking-[0.08em] text-text-3/50">Sessions</div>
607
618
  <div className="mt-1 text-text-2">
@@ -616,6 +627,28 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
616
627
  </div>
617
628
  </div>
618
629
  )}
630
+ {!inSidebar && environments.length > 0 && (
631
+ <div className="mt-3 flex flex-wrap gap-1.5">
632
+ {environments.slice(0, 4).map((environment) => (
633
+ <span
634
+ key={`${gateway.id}-${environment.id}`}
635
+ className={`rounded-full border px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] ${
636
+ environment.status === 'available'
637
+ ? 'border-emerald-400/20 bg-emerald-400/[0.06] text-emerald-300'
638
+ : 'border-white/[0.06] bg-white/[0.03] text-text-3/70'
639
+ }`}
640
+ title={environment.capabilities?.join(', ') || environment.id}
641
+ >
642
+ {environment.label || environment.id}
643
+ </span>
644
+ ))}
645
+ {environments.length > 4 && (
646
+ <span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] text-text-3/70">
647
+ +{environments.length - 4}
648
+ </span>
649
+ )}
650
+ </div>
651
+ )}
619
652
  {!inSidebar && (pendingPairings > 0 || topologyErrorCount > 0) && (
620
653
  <div className={`mt-3 rounded-[10px] border px-3 py-2 text-[11px] leading-relaxed ${
621
654
  topologyErrorCount > 0
@@ -462,6 +462,31 @@ export function TaskSheet() {
462
462
  {editing.executionWorkspace?.path && (
463
463
  <code className="block text-[12px] text-text-3 font-mono break-all">{editing.executionWorkspace.path}</code>
464
464
  )}
465
+ {(editing.executionWorkspace?.contextPath || editing.executionWorkspace?.envPath) && (
466
+ <div className="grid grid-cols-1 gap-2 text-[11px] text-text-3/70">
467
+ {editing.executionWorkspace.contextPath && (
468
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
469
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Context</div>
470
+ <code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.contextPath}</code>
471
+ </div>
472
+ )}
473
+ {editing.executionWorkspace.envPath && (
474
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
475
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Env</div>
476
+ <code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.envPath}</code>
477
+ </div>
478
+ )}
479
+ </div>
480
+ )}
481
+ {editing.executionWorkspace?.envHints?.length ? (
482
+ <div className="flex flex-wrap gap-1.5">
483
+ {editing.executionWorkspace.envHints.slice(0, 8).map((hint) => (
484
+ <InfoChip key={hint.key} tone="neutral" title={hint.value}>
485
+ <span className="max-w-[220px] truncate">{hint.key}</span>
486
+ </InfoChip>
487
+ ))}
488
+ </div>
489
+ ) : null}
465
490
  {previewLinks.length > 0 && (
466
491
  <div className="flex flex-wrap gap-2">
467
492
  {previewLinks.map((link) => (
@@ -942,6 +967,31 @@ export function TaskSheet() {
942
967
  {editing.executionWorkspace?.path && (
943
968
  <code className="block text-[12px] text-text-3 font-mono break-all">{editing.executionWorkspace.path}</code>
944
969
  )}
970
+ {(editing.executionWorkspace?.contextPath || editing.executionWorkspace?.envPath) && (
971
+ <div className="grid grid-cols-1 gap-2 text-[11px] text-text-3/70">
972
+ {editing.executionWorkspace.contextPath && (
973
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
974
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Context</div>
975
+ <code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.contextPath}</code>
976
+ </div>
977
+ )}
978
+ {editing.executionWorkspace.envPath && (
979
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
980
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Env</div>
981
+ <code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.envPath}</code>
982
+ </div>
983
+ )}
984
+ </div>
985
+ )}
986
+ {editing.executionWorkspace?.envHints?.length ? (
987
+ <div className="flex flex-wrap gap-1.5">
988
+ {editing.executionWorkspace.envHints.slice(0, 8).map((hint) => (
989
+ <InfoChip key={hint.key} tone="neutral" title={hint.value}>
990
+ <span className="max-w-[220px] truncate">{hint.key}</span>
991
+ </InfoChip>
992
+ ))}
993
+ </div>
994
+ ) : null}
945
995
  {previewLinks.length > 0 && (
946
996
  <div className="flex flex-wrap gap-2">
947
997
  {previewLinks.map((link) => (
@@ -3,6 +3,7 @@ import { api } from '@/lib/app/api-client'
3
3
  import { credentialQueryKeys } from '@/features/credentials/queries'
4
4
  import type {
5
5
  GatewayProfile,
6
+ OpenClawEnvironmentSummary,
6
7
  OpenClawDevicePairRequest,
7
8
  OpenClawGatewayFleetTopology,
8
9
  OpenClawGatewayPresenceEntry,
@@ -60,6 +61,7 @@ export interface RefreshGatewayTopologyResult {
60
61
  pairedDevices: OpenClawPairedDevice[]
61
62
  sessions: OpenClawGatewaySession[]
62
63
  presence: OpenClawGatewayPresenceEntry[]
64
+ environments: OpenClawEnvironmentSummary[]
63
65
  errors: OpenClawGatewayRpcError[]
64
66
  topology: OpenClawGatewayTopology
65
67
  }
@@ -190,6 +192,7 @@ export function useRefreshGatewayTopologyMutation() {
190
192
  pairedDevices: topology.pairedDevices,
191
193
  sessions: topology.sessions,
192
194
  presence: topology.presence,
195
+ environments: topology.environments,
193
196
  errors: topology.errors,
194
197
  topology,
195
198
  }
@@ -0,0 +1,159 @@
1
+ import assert from 'node:assert/strict'
2
+ import test, { afterEach } from 'node:test'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import os from 'node:os'
6
+
7
+ import { getExtensionManager } from './extensions'
8
+ import {
9
+ inspectExtensionLocalFolder,
10
+ listExtensionLocalFolderEntries,
11
+ listExtensionManagedResources,
12
+ reconcileExtensionManagedResources,
13
+ setExtensionLocalFolderConfig,
14
+ } from './extension-managed-resources'
15
+ import { loadAgents, loadSchedules, loadSettings, saveAgents, saveSchedules, saveSettings } from './storage'
16
+
17
+ const originalAgents = loadAgents()
18
+ const originalSchedules = loadSchedules()
19
+ const originalSettings = loadSettings()
20
+
21
+ let seq = 0
22
+
23
+ function extensionId(prefix: string): string {
24
+ seq += 1
25
+ return `${prefix}_${Date.now()}_${seq}`
26
+ }
27
+
28
+ afterEach(() => {
29
+ saveAgents(originalAgents)
30
+ saveSchedules(originalSchedules)
31
+ saveSettings(originalSettings)
32
+ })
33
+
34
+ test('managed resources summary and reconcile create extension-owned agents and schedules', () => {
35
+ const id = extensionId('managed_resources')
36
+ getExtensionManager().registerBuiltin(id, {
37
+ name: 'Managed Resource Fixture',
38
+ description: 'Declares resources for tests.',
39
+ managedResources: {
40
+ agents: [
41
+ {
42
+ agentKey: 'researcher',
43
+ displayName: 'Managed Researcher',
44
+ description: 'Research managed by an extension.',
45
+ systemPrompt: 'Research carefully.',
46
+ provider: 'openai',
47
+ model: 'gpt-4o-mini',
48
+ capabilities: ['research'],
49
+ extensions: ['web'],
50
+ },
51
+ ],
52
+ routines: [
53
+ {
54
+ routineKey: 'daily-digest',
55
+ title: 'Daily Digest',
56
+ assigneeRef: { resourceKind: 'agent', resourceKey: 'researcher' },
57
+ taskPrompt: 'Prepare a digest.',
58
+ triggers: [{ kind: 'schedule', cronExpression: '0 9 * * *', timezone: 'UTC' }],
59
+ },
60
+ ],
61
+ gatewayPlatforms: [
62
+ {
63
+ platformKey: 'openai-api',
64
+ displayName: 'OpenAI-compatible API',
65
+ transport: 'http',
66
+ endpoint: 'http://127.0.0.1:8642/v1',
67
+ },
68
+ ],
69
+ setupChecks: [
70
+ { checkKey: 'api-key', displayName: 'API key configured', kind: 'env', target: 'OPENAI_API_KEY' },
71
+ ],
72
+ },
73
+ })
74
+
75
+ const before = listExtensionManagedResources().extensions.find((entry) => entry.extensionId === id)
76
+ assert.ok(before)
77
+ assert.equal(before.agents[0].status, 'missing')
78
+ assert.equal(before.schedules[0].status, 'missing_ref')
79
+
80
+ const result = reconcileExtensionManagedResources(id)
81
+ assert.equal(result.createdAgents.length, 1)
82
+ assert.equal(result.createdSchedules.length, 1)
83
+ assert.deepEqual(result.skipped, [])
84
+
85
+ const agents = loadAgents()
86
+ const agent = agents[result.createdAgents[0]]
87
+ assert.equal(agent.name, 'Managed Researcher')
88
+ assert.equal(agent.managedByExtension?.extensionId, id)
89
+ assert.equal(agent.managedByExtension?.resourceKey, 'researcher')
90
+ assert.ok(agent.extensions?.includes(id))
91
+ assert.ok(agent.extensions?.includes('web'))
92
+
93
+ const schedules = loadSchedules()
94
+ const schedule = schedules[result.createdSchedules[0]]
95
+ assert.equal(schedule.name, 'Daily Digest')
96
+ assert.equal(schedule.agentId, agent.id)
97
+ assert.equal(schedule.scheduleType, 'cron')
98
+ assert.equal(schedule.cron, '0 9 * * *')
99
+ assert.equal(schedule.status, 'paused')
100
+ assert.equal(schedule.managedByExtension?.resourceKey, 'daily-digest')
101
+
102
+ const after = listExtensionManagedResources().extensions.find((entry) => entry.extensionId === id)
103
+ assert.equal(after?.agents[0].status, 'resolved')
104
+ assert.equal(after?.schedules[0].status, 'resolved')
105
+ assert.equal(after?.gatewayPlatforms.length, 1)
106
+ assert.equal(after?.setupChecks.length, 1)
107
+ })
108
+
109
+ test('local folder inspection and listing stay inside configured roots', async () => {
110
+ const id = extensionId('managed_folder')
111
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-managed-folder-'))
112
+ fs.mkdirSync(path.join(tempDir, 'inputs'))
113
+ fs.mkdirSync(path.join(tempDir, 'outputs'))
114
+ fs.writeFileSync(path.join(tempDir, 'inputs', 'brief.txt'), 'hello\n')
115
+
116
+ getExtensionManager().registerBuiltin(id, {
117
+ name: 'Managed Folder Fixture',
118
+ managedResources: {
119
+ localFolders: [
120
+ {
121
+ folderKey: 'workspace',
122
+ displayName: 'Workspace Folder',
123
+ access: 'readWrite',
124
+ requiredDirectories: ['inputs', 'outputs'],
125
+ requiredFiles: ['inputs/brief.txt'],
126
+ },
127
+ ],
128
+ },
129
+ })
130
+
131
+ setExtensionLocalFolderConfig({
132
+ extensionId: id,
133
+ folderKey: 'workspace',
134
+ path: tempDir,
135
+ })
136
+
137
+ const status = await inspectExtensionLocalFolder({ extensionId: id, folderKey: 'workspace' })
138
+ assert.equal(status.healthy, true)
139
+ assert.equal(status.readable, true)
140
+ assert.equal(status.writable, true)
141
+
142
+ const listing = await listExtensionLocalFolderEntries({
143
+ extensionId: id,
144
+ folderKey: 'workspace',
145
+ recursive: true,
146
+ })
147
+ assert.ok(listing.entries.some((entry) => entry.path === 'inputs/brief.txt' && entry.kind === 'file'))
148
+
149
+ await assert.rejects(
150
+ () => listExtensionLocalFolderEntries({
151
+ extensionId: id,
152
+ folderKey: 'workspace',
153
+ relativePath: '../outside',
154
+ }),
155
+ /inside the configured root|traversal/,
156
+ )
157
+
158
+ fs.rmSync(tempDir, { recursive: true, force: true })
159
+ })