1+ from app .objects .secondclass .c_fact import Fact
12from app .objects .secondclass .c_link import Link
23from app .service .interfaces .i_planning_svc import PlanningServiceInterface
34from app .utility .base_planning_svc import BasePlanningService
@@ -142,7 +143,8 @@ async def get_links(self, operation, buckets=None, agent=None, trim=True):
142143
143144 For an operation and agent combination, create links (that can
144145 be executed). When no agent is supplied, links for all agents
145- are returned.
146+ are returned. Supports both legacy string ability IDs and
147+ dict-style steps with per-step metadata in atomic_ordering.
146148
147149 :param operation: Operation to generate links for
148150 :type operation: Operation
@@ -158,25 +160,56 @@ async def get_links(self, operation, buckets=None, agent=None, trim=True):
158160 :type trim: bool, optional
159161 :return: a list of links sorted by score and atomic ordering
160162 """
161- ao = operation .adversary .atomic_ordering
162- abilities = await self .get_service ('data_svc' ) \
163- .locate ('abilities' , match = dict (ability_id = tuple (ao )))
163+ raw_steps = operation .adversary .atomic_ordering
164+ step_entries = []
165+ unique_ids = set ()
166+
167+ for idx , step in enumerate (raw_steps ):
168+ if isinstance (step , str ):
169+ ability_id = step
170+ metadata = {}
171+ else :
172+ ability_id = step .get ('ability_id' )
173+ metadata = step .get ('metadata' , {})
174+
175+ step_entries .append ({
176+ 'step_idx' : idx ,
177+ 'ability_id' : ability_id ,
178+ 'metadata' : metadata ,
179+ })
180+ unique_ids .add (ability_id )
181+
182+ all_abilities = await self .get_service ('data_svc' ).locate (
183+ 'abilities' , match = dict (ability_id = tuple (unique_ids ))
184+ )
185+ ability_map = {a .ability_id : a for a in all_abilities }
186+
187+ steps_with_abilities = []
188+ for step in step_entries :
189+ ability = ability_map .get (step ['ability_id' ])
190+ if not ability :
191+ continue
192+ steps_with_abilities .append ({
193+ 'step_idx' : step ['step_idx' ],
194+ 'ability' : ability ,
195+ 'metadata' : step ['metadata' ],
196+ })
197+
164198 if buckets :
165- # buckets specified - get all links for given buckets,
166- # (still in underlying atomic adversary order)
167- t = []
168- for bucket in buckets :
169- t .extend ([ab for ab in abilities for b in ab .buckets if b == bucket ])
170- abilities = t
199+ steps_with_abilities = [
200+ s for s in steps_with_abilities
201+ if any (bucket in s ['ability' ].buckets for bucket in buckets )
202+ ]
203+
171204 links = []
172205 if agent :
173- links .extend (await self .generate_and_trim_links (agent , operation , abilities , trim ))
206+ links .extend (await self .generate_and_trim_links (agent , operation , steps_with_abilities , trim ))
174207 else :
175208 agent_links = []
176209 for agent in operation .agents :
177- agent_links .append (await self .generate_and_trim_links (agent , operation , abilities , trim ))
210+ agent_links .append (await self .generate_and_trim_links (agent , operation , steps_with_abilities , trim ))
178211 links = await self ._remove_links_of_duplicate_singletons (agent_links )
179- self .log .debug ('Generated %s usable links' % ( len (links ) ))
212+ self .log .debug ('Generated %s usable links' , len (links ))
180213 return await self .sort_links (links )
181214
182215 async def get_cleanup_links (self , operation , agent = None ):
@@ -332,33 +365,91 @@ async def _check_and_generate_cleanup_links(self, agent, operation):
332365 link_status = operation .link_status ())
333366 return agent_cleanup_links
334367
335- async def _generate_new_links (self , operation , agent , abilities , link_status ):
336- """Generate links with given status
368+ async def _generate_new_links (self , operation , agent , steps_with_abilities , link_status ):
369+ """Generate links with given status, supporting per-step metadata.
370+
371+ Each entry in steps_with_abilities is a dict with keys:
372+ step_idx - ordinal position in the adversary profile
373+ ability - resolved Ability object
374+ metadata - dict of per-step metadata (may contain executor_facts)
337375
338376 :param operation: Operation to generate links on
339377 :type operation: Operation
340378 :param agent: Agent to generate links on
341379 :type agent: Agent
342- :param agent: Abilities to generate links for
343- :type agent : list(Ability )
380+ :param steps_with_abilities: Steps with resolved abilities and metadata
381+ :type steps_with_abilities : list(dict )
344382 :param link_status: Link status, referencing link state dict
345383 :type link_status: int
346384 :return: Links for agent
347385 :rtype: list(Link)
348386 """
349387 links = []
350- for ability in await agent .capabilities (abilities ):
388+ for step in steps_with_abilities :
389+ idx = step ['step_idx' ]
390+ ability = step ['ability' ]
391+ step_meta = step .get ('metadata' , {})
392+
393+ supported_abilities = await agent .capabilities ([ability ])
394+ if ability .ability_id not in [a .ability_id for a in supported_abilities ]:
395+ continue
396+
351397 executor = await agent .get_preferred_executor (ability )
352398 if not executor :
353399 continue
400+
354401 await self ._call_ability_plugin_hooks (ability , executor )
355- if executor .command :
356- link = Link .load (dict (command = self .encode_string (executor .test ), paw = agent .paw , score = 0 ,
357- ability = ability , executor = executor , status = link_status ,
358- jitter = self .jitter (operation .jitter )))
359- links .append (link )
402+
403+ # Collect per-step executor_facts if present
404+ fact_dicts = []
405+ exec_facts = step_meta .get ('executor_facts' , {}).get (executor .platform , [])
406+ for f in exec_facts :
407+ try :
408+ fact_dicts .append ({f ['trait' ].strip (): f ['value' ].strip ('" ' )})
409+ except (KeyError , AttributeError ) as e :
410+ self .log .warning ('Skipping malformed fact in step %s: %s (%s)' , idx , f , e )
411+
412+ link = Link .load (dict (
413+ paw = agent .paw ,
414+ score = 0 ,
415+ ability = ability ,
416+ executor = executor ,
417+ status = link_status ,
418+ jitter = self .jitter (operation .jitter ),
419+ ))
420+ link .step_idx = idx
421+
422+ if fact_dicts :
423+ for fd in fact_dicts :
424+ for trait , value in fd .items ():
425+ link .used .append (Fact (trait = trait , value = value , source = operation .id ))
426+ injected = self ._inject_facts (executor .test , fact_dicts )
427+ else :
428+ injected , _ , used = await self ._build_single_test_variant (executor .test , [], executor .name )
429+ for f in used :
430+ link .used .append (f )
431+
432+ link .command = self .encode_string (injected )
433+ links .append (link )
434+
360435 return links
361436
437+ @staticmethod
438+ def _inject_facts (template , fact_dicts ):
439+ """Replace #{trait} placeholders in the template using fact dicts.
440+
441+ :param template: Command template with #{trait} placeholders
442+ :type template: str
443+ :param fact_dicts: List of {trait: value} dicts
444+ :type fact_dicts: list(dict)
445+ :return: Template with placeholders replaced
446+ :rtype: str
447+ """
448+ for entry in fact_dicts :
449+ for trait , value in entry .items ():
450+ template = template .replace (f'#{{{ trait } }}' , value )
451+ return template
452+
362453 async def _generate_cleanup_links (self , operation , agent , link_status ):
363454 """Generate cleanup links with given status
364455
0 commit comments