ace-assign 0.37.0 → 0.40.3
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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/composition-rules.yml +12 -2
- data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +4 -0
- data/.ace-defaults/assign/catalog/steps/record-demo.step.yml +39 -0
- data/.ace-defaults/assign/presets/work-on-task.yml +37 -0
- data/CHANGELOG.md +126 -0
- data/README.md +3 -2
- data/docs/getting-started.md +16 -5
- data/docs/handbook.md +2 -0
- data/docs/usage.md +32 -10
- data/handbook/skills/as-assign-add-task/SKILL.md +24 -0
- data/handbook/workflow-instructions/assign/add-task.wf.md +146 -0
- data/handbook/workflow-instructions/assign/create.wf.md +15 -4
- data/handbook/workflow-instructions/assign/prepare.wf.md +64 -3
- data/lib/ace/assign/atoms/preset_loader.rb +49 -0
- data/lib/ace/assign/atoms/preset_step_resolver.rb +70 -0
- data/lib/ace/assign/cli/commands/add.rb +269 -65
- data/lib/ace/assign/cli/commands/create.rb +35 -6
- data/lib/ace/assign/cli.rb +3 -0
- data/lib/ace/assign/molecules/preset_inferrer.rb +31 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +335 -11
- data/lib/ace/assign/organisms/task_assignment_creator.rb +176 -0
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -0
- metadata +23 -2
|
@@ -4,21 +4,33 @@ module Ace
|
|
|
4
4
|
module Assign
|
|
5
5
|
module CLI
|
|
6
6
|
module Commands
|
|
7
|
-
# Create a new workflow assignment from
|
|
7
|
+
# Create a new workflow assignment from YAML or preset-backed task refs
|
|
8
8
|
class Create < Ace::Support::Cli::Command
|
|
9
9
|
include Ace::Support::Cli::Base
|
|
10
10
|
|
|
11
|
-
desc "Create a new workflow assignment
|
|
11
|
+
desc "Create a new workflow assignment"
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
option :yaml, desc: "Path to job.yaml config file"
|
|
14
|
+
option :task, aliases: ["-t"], type: :array,
|
|
15
|
+
desc: "Task reference(s), repeatable and comma-separated"
|
|
16
|
+
option :preset, aliases: ["-p"], desc: "Assignment preset name (task mode only)"
|
|
14
17
|
option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
|
|
15
18
|
option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
|
|
16
19
|
|
|
17
|
-
def call(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
def call(yaml: nil, task: nil, preset: nil, **options)
|
|
21
|
+
validate_modes!(yaml, task, preset)
|
|
22
|
+
|
|
23
|
+
result = if yaml
|
|
24
|
+
Organisms::AssignmentExecutor.new.start(yaml)
|
|
25
|
+
else
|
|
26
|
+
Organisms::TaskAssignmentCreator.new.call(
|
|
27
|
+
task_refs: task,
|
|
28
|
+
preset_name: preset || Organisms::TaskAssignmentCreator::DEFAULT_PRESET
|
|
29
|
+
)
|
|
30
|
+
end
|
|
20
31
|
|
|
21
32
|
unless options[:quiet]
|
|
33
|
+
print_terminal_skip_summary(result[:skipped_terminal])
|
|
22
34
|
print_assignment_header(result[:assignment])
|
|
23
35
|
print_step_instructions(result[:current])
|
|
24
36
|
end
|
|
@@ -26,6 +38,23 @@ module Ace
|
|
|
26
38
|
|
|
27
39
|
private
|
|
28
40
|
|
|
41
|
+
def validate_modes!(yaml, task, preset)
|
|
42
|
+
task_refs = Array(task).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
43
|
+
selected = 0
|
|
44
|
+
selected += 1 if yaml && !yaml.to_s.strip.empty?
|
|
45
|
+
selected += 1 if task_refs.any?
|
|
46
|
+
|
|
47
|
+
raise Ace::Support::Cli::Error, "Exactly one of --yaml or --task is required" if selected.zero?
|
|
48
|
+
raise Ace::Support::Cli::Error, "--yaml and --task are mutually exclusive" if selected > 1
|
|
49
|
+
raise Ace::Support::Cli::Error, "--preset requires --task" if preset && task_refs.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def print_terminal_skip_summary(skipped_terminal)
|
|
53
|
+
return if skipped_terminal.nil? || skipped_terminal.empty?
|
|
54
|
+
|
|
55
|
+
puts "Skipped terminal tasks (done/skipped/cancelled): #{skipped_terminal.join(', ')}"
|
|
56
|
+
end
|
|
57
|
+
|
|
29
58
|
def print_assignment_header(assignment)
|
|
30
59
|
puts "Assignment: #{assignment.name} (#{assignment.id})"
|
|
31
60
|
puts "Created: #{display_path(assignment.cache_dir)}/"
|
data/lib/ace/assign/cli.rb
CHANGED
|
@@ -14,6 +14,8 @@ require_relative "models/assignment_info"
|
|
|
14
14
|
require_relative "atoms/step_numbering"
|
|
15
15
|
require_relative "atoms/number_generator"
|
|
16
16
|
require_relative "atoms/preset_expander"
|
|
17
|
+
require_relative "atoms/preset_loader"
|
|
18
|
+
require_relative "atoms/preset_step_resolver"
|
|
17
19
|
require_relative "atoms/step_file_parser"
|
|
18
20
|
require_relative "atoms/step_sorter"
|
|
19
21
|
require_relative "atoms/catalog_loader"
|
|
@@ -29,6 +31,7 @@ require_relative "molecules/step_writer"
|
|
|
29
31
|
require_relative "molecules/step_renumberer"
|
|
30
32
|
require_relative "molecules/skill_assign_source_resolver"
|
|
31
33
|
require_relative "molecules/fork_session_launcher"
|
|
34
|
+
require_relative "molecules/preset_inferrer"
|
|
32
35
|
|
|
33
36
|
# Organisms
|
|
34
37
|
require_relative "organisms/assignment_executor"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Molecules
|
|
8
|
+
# Infers assignment preset from archived source config.
|
|
9
|
+
module PresetInferrer
|
|
10
|
+
DEFAULT_PRESET = "work-on-task"
|
|
11
|
+
|
|
12
|
+
def self.infer_from_assignment(assignment)
|
|
13
|
+
return DEFAULT_PRESET unless assignment
|
|
14
|
+
|
|
15
|
+
source_path = assignment.source_config.to_s
|
|
16
|
+
return DEFAULT_PRESET if source_path.empty? || !File.exist?(source_path)
|
|
17
|
+
|
|
18
|
+
data = YAML.safe_load_file(source_path, aliases: true)
|
|
19
|
+
return DEFAULT_PRESET unless data.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
session_name = data.dig("session", "name").to_s.strip
|
|
22
|
+
return session_name unless session_name.empty?
|
|
23
|
+
|
|
24
|
+
DEFAULT_PRESET
|
|
25
|
+
rescue Psych::SyntaxError, Errno::ENOENT
|
|
26
|
+
DEFAULT_PRESET
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -11,6 +11,8 @@ module Ace
|
|
|
11
11
|
# Implements the state machine for queue operations:
|
|
12
12
|
# start → advance → complete (with fail/add/retry branches)
|
|
13
13
|
class AssignmentExecutor
|
|
14
|
+
DEFAULT_DYNAMIC_STEP_INSTRUCTIONS = "Complete this step and finish with: ace-assign finish --message report.md".freeze
|
|
15
|
+
|
|
14
16
|
attr_reader :assignment_manager, :queue_scanner, :step_writer, :step_renumberer, :skill_source_resolver
|
|
15
17
|
|
|
16
18
|
def initialize(cache_base: nil)
|
|
@@ -105,7 +107,7 @@ module Ace
|
|
|
105
107
|
# @return [Hash] Result with assignment and state
|
|
106
108
|
def status
|
|
107
109
|
assignment = assignment_manager.find_active
|
|
108
|
-
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
110
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
|
|
109
111
|
|
|
110
112
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
111
113
|
{
|
|
@@ -127,7 +129,7 @@ module Ace
|
|
|
127
129
|
# @return [Hash] Result with started step and updated state
|
|
128
130
|
def start_step(step_number: nil, fork_root: nil)
|
|
129
131
|
assignment = assignment_manager.find_active
|
|
130
|
-
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
132
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
|
|
131
133
|
|
|
132
134
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
133
135
|
raise StepErrors::InvalidState, "Cannot start: step #{state.current.number} is already in progress. Finish or fail it first." if state.current
|
|
@@ -169,7 +171,7 @@ module Ace
|
|
|
169
171
|
# @return [Hash] Result with completed step and updated state
|
|
170
172
|
def finish_step(report_content:, step_number: nil, fork_root: nil)
|
|
171
173
|
assignment = assignment_manager.find_active
|
|
172
|
-
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
174
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
|
|
173
175
|
|
|
174
176
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
175
177
|
current = find_target_step_for_finish(state, step_number, fork_root)
|
|
@@ -260,7 +262,7 @@ module Ace
|
|
|
260
262
|
# @return [Hash] Result with updated state
|
|
261
263
|
def fail(message)
|
|
262
264
|
assignment = assignment_manager.find_active
|
|
263
|
-
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
265
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
|
|
264
266
|
|
|
265
267
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
266
268
|
current = state.current
|
|
@@ -288,9 +290,12 @@ module Ace
|
|
|
288
290
|
# @param after [String, nil] Insert after this step number (optional)
|
|
289
291
|
# @param as_child [Boolean] Insert as child of 'after' step (default: false, sibling)
|
|
290
292
|
# @return [Hash] Result with new step
|
|
291
|
-
def add(name, instructions, after: nil, as_child: false)
|
|
293
|
+
def add(name, instructions, after: nil, as_child: false, added_by: nil, extra: {})
|
|
292
294
|
assignment = assignment_manager.find_active
|
|
293
|
-
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
295
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
|
|
296
|
+
|
|
297
|
+
step_name = name.to_s.strip
|
|
298
|
+
raise Error, "Step name cannot be empty." if step_name.empty?
|
|
294
299
|
|
|
295
300
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
296
301
|
existing_numbers = queue_scanner.step_numbers(assignment.steps_dir)
|
|
@@ -318,7 +323,7 @@ module Ace
|
|
|
318
323
|
initial_status = state.current ? :pending : :in_progress
|
|
319
324
|
|
|
320
325
|
# Build added_by metadata for audit trail
|
|
321
|
-
added_by
|
|
326
|
+
added_by ||= if after && as_child
|
|
322
327
|
"child_of:#{after}"
|
|
323
328
|
elsif after
|
|
324
329
|
"injected_after:#{after}"
|
|
@@ -326,15 +331,18 @@ module Ace
|
|
|
326
331
|
"dynamic"
|
|
327
332
|
end
|
|
328
333
|
|
|
334
|
+
extra_frontmatter = normalize_batch_extra_fields(extra)
|
|
335
|
+
|
|
329
336
|
# Create new step file with correct status
|
|
330
337
|
step_writer.create(
|
|
331
338
|
steps_dir: assignment.steps_dir,
|
|
332
339
|
number: new_number,
|
|
333
|
-
name:
|
|
340
|
+
name: step_name,
|
|
334
341
|
instructions: instructions,
|
|
335
342
|
status: initial_status,
|
|
336
343
|
added_by: added_by,
|
|
337
|
-
parent: as_child ? after : nil
|
|
344
|
+
parent: as_child ? after : nil,
|
|
345
|
+
extra: extra_frontmatter
|
|
338
346
|
)
|
|
339
347
|
|
|
340
348
|
rebalance_after_child_injection(assignment: assignment, state: state, parent_number: after) if as_child && after
|
|
@@ -354,13 +362,64 @@ module Ace
|
|
|
354
362
|
}
|
|
355
363
|
end
|
|
356
364
|
|
|
365
|
+
# Add multiple steps dynamically from a pre-parsed steps array.
|
|
366
|
+
#
|
|
367
|
+
# @param steps [Array<Hash>] Step definitions loaded from YAML
|
|
368
|
+
# @param after [String, nil] Insert after this step number
|
|
369
|
+
# @param as_child [Boolean] Insert as children of +after+
|
|
370
|
+
# @param source_file [String, nil] Source YAML path (for added_by audit metadata)
|
|
371
|
+
# @note Structural validation is performed for the full batch before any writes.
|
|
372
|
+
# Runtime I/O failures can still interrupt insertion after partial writes.
|
|
373
|
+
# @return [Hash] Result with added steps and final state
|
|
374
|
+
def add_batch(steps:, after: nil, as_child: false, source_file: nil)
|
|
375
|
+
unless steps.is_a?(Array) && steps.any?
|
|
376
|
+
source_label = source_file.to_s.strip.empty? ? "batch input" : source_file
|
|
377
|
+
raise Error, "No steps defined in #{source_label}"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
if as_child && (after.nil? || after.to_s.strip.empty?)
|
|
381
|
+
raise Error, "Child insertion requires an after step reference."
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
source_label = source_file.to_s.strip.empty? ? "batch" : File.basename(source_file.to_s)
|
|
385
|
+
batch_added_by = "batch_from:#{source_label}"
|
|
386
|
+
|
|
387
|
+
prevalidate_batch_trees!(steps)
|
|
388
|
+
|
|
389
|
+
added_steps = []
|
|
390
|
+
renumbered = []
|
|
391
|
+
sibling_cursor = after
|
|
392
|
+
|
|
393
|
+
steps.each_with_index do |step_config, index|
|
|
394
|
+
inserted = insert_batch_step_tree(
|
|
395
|
+
step_config,
|
|
396
|
+
after: as_child ? after : sibling_cursor,
|
|
397
|
+
as_child: as_child,
|
|
398
|
+
added_by: batch_added_by,
|
|
399
|
+
location: "steps[#{index}]"
|
|
400
|
+
)
|
|
401
|
+
added_steps.concat(inserted[:added])
|
|
402
|
+
renumbered.concat(inserted[:renumbered])
|
|
403
|
+
sibling_cursor = inserted[:root_number] unless as_child
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
assignment = assignment_manager.find_active
|
|
407
|
+
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
408
|
+
{
|
|
409
|
+
assignment: assignment,
|
|
410
|
+
state: state,
|
|
411
|
+
added: added_steps,
|
|
412
|
+
renumbered: renumbered.uniq
|
|
413
|
+
}
|
|
414
|
+
end
|
|
415
|
+
|
|
357
416
|
# Retry a failed step (creates new step linked to original)
|
|
358
417
|
#
|
|
359
418
|
# @param step_ref [String] Step number or reference to retry
|
|
360
419
|
# @return [Hash] Result with new retry step
|
|
361
420
|
def retry_step(step_ref)
|
|
362
421
|
assignment = assignment_manager.find_active
|
|
363
|
-
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
|
|
422
|
+
raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create --yaml <job.yaml>' to begin." unless assignment
|
|
364
423
|
|
|
365
424
|
state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
|
|
366
425
|
|
|
@@ -1119,6 +1178,267 @@ module Ace
|
|
|
1119
1178
|
instructions.is_a?(Array) ? instructions.join("\n") : instructions.to_s
|
|
1120
1179
|
end
|
|
1121
1180
|
|
|
1181
|
+
def insert_batch_step_tree(step_config, after:, as_child:, added_by:, location:)
|
|
1182
|
+
normalized = normalize_batch_step_hash(step_config, location: location)
|
|
1183
|
+
|
|
1184
|
+
if canonical_batch_insert_requested?(normalized)
|
|
1185
|
+
canonical_inserted = insert_canonical_batch_step_tree(
|
|
1186
|
+
normalized,
|
|
1187
|
+
after: after,
|
|
1188
|
+
as_child: as_child,
|
|
1189
|
+
added_by: added_by,
|
|
1190
|
+
location: location
|
|
1191
|
+
)
|
|
1192
|
+
return canonical_inserted if canonical_inserted
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
prepared = materialize_batch_step_config(normalized)
|
|
1196
|
+
instructions = normalize_instructions(prepared["instructions"])
|
|
1197
|
+
|
|
1198
|
+
result = add(
|
|
1199
|
+
prepared["name"],
|
|
1200
|
+
instructions,
|
|
1201
|
+
after: after,
|
|
1202
|
+
as_child: as_child,
|
|
1203
|
+
added_by: added_by,
|
|
1204
|
+
extra: prepared
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
root_step = result[:added]
|
|
1208
|
+
added_steps = [root_step]
|
|
1209
|
+
renumbered = Array(result[:renumbered])
|
|
1210
|
+
|
|
1211
|
+
normalize_batch_sub_steps(prepared, location: location).each_with_index do |child_config, index|
|
|
1212
|
+
child_inserted = insert_batch_step_tree(
|
|
1213
|
+
child_config,
|
|
1214
|
+
after: root_step.number,
|
|
1215
|
+
as_child: true,
|
|
1216
|
+
added_by: added_by,
|
|
1217
|
+
location: "#{location}.sub_steps[#{index}]"
|
|
1218
|
+
)
|
|
1219
|
+
added_steps.concat(child_inserted[:added])
|
|
1220
|
+
renumbered.concat(child_inserted[:renumbered])
|
|
1221
|
+
end
|
|
1222
|
+
|
|
1223
|
+
{added: added_steps, renumbered: renumbered, root_number: root_step.number}
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
def canonical_batch_insert_requested?(step_config)
|
|
1227
|
+
raw_sub_steps = step_config["sub_steps"] || step_config["sub-steps"]
|
|
1228
|
+
has_declared_sub_steps = raw_sub_steps.is_a?(Array) && raw_sub_steps.any?
|
|
1229
|
+
has_workflow = !step_config["workflow"].to_s.strip.empty?
|
|
1230
|
+
has_skill = !step_config["skill"].to_s.strip.empty?
|
|
1231
|
+
|
|
1232
|
+
has_declared_sub_steps || has_workflow || has_skill
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
def insert_canonical_batch_step_tree(step_config, after:, as_child:, added_by:, location:)
|
|
1236
|
+
materialized_tree = materialize_canonical_batch_tree(step_config, location: location)
|
|
1237
|
+
return nil if materialized_tree.nil? || materialized_tree.empty?
|
|
1238
|
+
|
|
1239
|
+
root_template = materialized_tree.find { |step| step["parent"].nil? } || materialized_tree.first
|
|
1240
|
+
root_instructions = normalize_instructions(root_template["instructions"])
|
|
1241
|
+
root_instructions = default_dynamic_step_instructions if root_instructions.strip.empty?
|
|
1242
|
+
|
|
1243
|
+
root_result = add(
|
|
1244
|
+
root_template["name"],
|
|
1245
|
+
root_instructions,
|
|
1246
|
+
after: after,
|
|
1247
|
+
as_child: as_child,
|
|
1248
|
+
added_by: added_by,
|
|
1249
|
+
extra: root_template
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
root_step = root_result[:added]
|
|
1253
|
+
added_steps = [root_step]
|
|
1254
|
+
renumbered = Array(root_result[:renumbered])
|
|
1255
|
+
|
|
1256
|
+
root_number = root_template["number"]
|
|
1257
|
+
children = materialized_tree
|
|
1258
|
+
.select { |step| step["parent"] == root_number }
|
|
1259
|
+
.sort_by { |step| step["number"].to_s }
|
|
1260
|
+
|
|
1261
|
+
children.each do |child_template|
|
|
1262
|
+
child_instructions = normalize_instructions(child_template["instructions"])
|
|
1263
|
+
child_instructions = default_dynamic_step_instructions if child_instructions.strip.empty?
|
|
1264
|
+
child_result = add(
|
|
1265
|
+
child_template["name"],
|
|
1266
|
+
child_instructions,
|
|
1267
|
+
after: root_step.number,
|
|
1268
|
+
as_child: true,
|
|
1269
|
+
added_by: added_by,
|
|
1270
|
+
extra: child_template
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
added_steps << child_result[:added]
|
|
1274
|
+
renumbered.concat(Array(child_result[:renumbered]))
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1277
|
+
{added: added_steps, renumbered: renumbered, root_number: root_step.number}
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
def materialize_canonical_batch_tree(step_config, location:)
|
|
1281
|
+
canonical_input, child_overrides = prepare_canonical_batch_input(step_config, location: location)
|
|
1282
|
+
return nil unless canonical_input
|
|
1283
|
+
|
|
1284
|
+
expanded = expand_sub_steps([canonical_input])
|
|
1285
|
+
expanded = apply_canonical_child_overrides(expanded, child_overrides)
|
|
1286
|
+
materialize_skill_backed_steps(expanded)
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
def prepare_canonical_batch_input(step_config, location:)
|
|
1290
|
+
enriched = enrich_declared_sub_steps([step_config]).first
|
|
1291
|
+
raw_sub_steps = enriched["sub_steps"] || enriched["sub-steps"]
|
|
1292
|
+
descriptors = parse_canonical_sub_step_descriptors(raw_sub_steps, location: "#{location}.sub_steps")
|
|
1293
|
+
return [nil, {}] if descriptors.nil?
|
|
1294
|
+
|
|
1295
|
+
names = descriptors[:names]
|
|
1296
|
+
overrides = descriptors[:overrides]
|
|
1297
|
+
canonical = enriched.dup
|
|
1298
|
+
if names
|
|
1299
|
+
canonical["sub_steps"] = names
|
|
1300
|
+
canonical.delete("sub-steps")
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
[canonical, overrides]
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
def parse_canonical_sub_step_descriptors(raw_sub_steps, location:)
|
|
1307
|
+
return {names: nil, overrides: {}} if raw_sub_steps.nil?
|
|
1308
|
+
return nil unless raw_sub_steps.is_a?(Array)
|
|
1309
|
+
|
|
1310
|
+
names = []
|
|
1311
|
+
overrides = {}
|
|
1312
|
+
|
|
1313
|
+
raw_sub_steps.each_with_index do |entry, index|
|
|
1314
|
+
case entry
|
|
1315
|
+
when String
|
|
1316
|
+
name = entry.to_s.strip
|
|
1317
|
+
raise Error, "sub_steps entry at #{location}[#{index}] cannot be empty" if name.empty?
|
|
1318
|
+
|
|
1319
|
+
names << name
|
|
1320
|
+
when Hash
|
|
1321
|
+
normalized = normalize_batch_step_hash(entry, location: "#{location}[#{index}]")
|
|
1322
|
+
return nil if normalized.key?("sub_steps") || normalized.key?("sub-steps")
|
|
1323
|
+
|
|
1324
|
+
names << normalized["name"]
|
|
1325
|
+
overrides[index] = normalized
|
|
1326
|
+
else
|
|
1327
|
+
return nil
|
|
1328
|
+
end
|
|
1329
|
+
end
|
|
1330
|
+
|
|
1331
|
+
{names: names, overrides: overrides}
|
|
1332
|
+
end
|
|
1333
|
+
|
|
1334
|
+
def apply_canonical_child_overrides(expanded_steps, overrides)
|
|
1335
|
+
return expanded_steps if overrides.empty?
|
|
1336
|
+
|
|
1337
|
+
root = expanded_steps.find { |step| step["parent"].nil? } || expanded_steps.first
|
|
1338
|
+
root_number = root["number"]
|
|
1339
|
+
children = expanded_steps
|
|
1340
|
+
.select { |step| step["parent"] == root_number }
|
|
1341
|
+
.sort_by { |step| step["number"].to_s }
|
|
1342
|
+
|
|
1343
|
+
merged_children = children.each_with_index.map do |child, index|
|
|
1344
|
+
override = overrides[index]
|
|
1345
|
+
next child unless override
|
|
1346
|
+
|
|
1347
|
+
child.merge(override).merge(
|
|
1348
|
+
"number" => child["number"],
|
|
1349
|
+
"parent" => child["parent"]
|
|
1350
|
+
)
|
|
1351
|
+
end
|
|
1352
|
+
|
|
1353
|
+
[root] + merged_children
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1356
|
+
def materialize_batch_step_config(step_config)
|
|
1357
|
+
prepared = materialize_skill_backed_step(step_config)
|
|
1358
|
+
instructions = normalize_instructions(prepared["instructions"])
|
|
1359
|
+
prepared["instructions"] = default_dynamic_step_instructions if instructions.strip.empty?
|
|
1360
|
+
prepared
|
|
1361
|
+
end
|
|
1362
|
+
|
|
1363
|
+
def prevalidate_batch_trees!(steps)
|
|
1364
|
+
steps.each_with_index do |step_config, index|
|
|
1365
|
+
prevalidate_batch_step_tree(step_config, location: "steps[#{index}]")
|
|
1366
|
+
end
|
|
1367
|
+
end
|
|
1368
|
+
|
|
1369
|
+
def prevalidate_batch_step_tree(step_config, location:)
|
|
1370
|
+
normalized = normalize_batch_step_hash(step_config, location: location)
|
|
1371
|
+
|
|
1372
|
+
if canonical_batch_insert_requested?(normalized)
|
|
1373
|
+
materialized_tree = materialize_canonical_batch_tree(normalized, location: location)
|
|
1374
|
+
return if materialized_tree
|
|
1375
|
+
end
|
|
1376
|
+
|
|
1377
|
+
prepared = materialize_batch_step_config(normalized)
|
|
1378
|
+
normalize_batch_sub_steps(prepared, location: location).each_with_index do |child_config, index|
|
|
1379
|
+
prevalidate_batch_step_tree(child_config, location: "#{location}.sub_steps[#{index}]")
|
|
1380
|
+
end
|
|
1381
|
+
end
|
|
1382
|
+
|
|
1383
|
+
def normalize_batch_step_hash(step_config, location:)
|
|
1384
|
+
unless step_config.is_a?(Hash)
|
|
1385
|
+
raise Error, "Invalid step definition at #{location}: expected mapping"
|
|
1386
|
+
end
|
|
1387
|
+
|
|
1388
|
+
normalized = step_config.each_with_object({}) do |(key, value), memo|
|
|
1389
|
+
memo[key.to_s] = value
|
|
1390
|
+
end
|
|
1391
|
+
|
|
1392
|
+
name = normalized["name"].to_s.strip
|
|
1393
|
+
raise Error, "Step name is required at #{location}" if name.empty?
|
|
1394
|
+
|
|
1395
|
+
normalized["name"] = name
|
|
1396
|
+
normalized
|
|
1397
|
+
end
|
|
1398
|
+
|
|
1399
|
+
def normalize_batch_sub_steps(step_config, location:)
|
|
1400
|
+
raw = step_config["sub_steps"] || step_config["sub-steps"]
|
|
1401
|
+
return [] unless raw
|
|
1402
|
+
|
|
1403
|
+
unless raw.is_a?(Array)
|
|
1404
|
+
raise Error, "sub_steps must be an array at #{location}"
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1407
|
+
raw.each_with_index.map do |entry, index|
|
|
1408
|
+
case entry
|
|
1409
|
+
when String
|
|
1410
|
+
name = entry.to_s.strip
|
|
1411
|
+
raise Error, "sub_steps entry at #{location}[#{index}] cannot be empty" if name.empty?
|
|
1412
|
+
|
|
1413
|
+
{
|
|
1414
|
+
"name" => name,
|
|
1415
|
+
"instructions" => "Execute #{name} step."
|
|
1416
|
+
}
|
|
1417
|
+
when Hash
|
|
1418
|
+
normalized = normalize_batch_step_hash(entry, location: "#{location}[#{index}]")
|
|
1419
|
+
materialize_batch_step_config(normalized)
|
|
1420
|
+
else
|
|
1421
|
+
raise Error, "Invalid sub_steps entry at #{location}[#{index}]: expected string or mapping"
|
|
1422
|
+
end
|
|
1423
|
+
end
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
def normalize_batch_extra_fields(step_config)
|
|
1427
|
+
return {} unless step_config.is_a?(Hash)
|
|
1428
|
+
return {} if step_config.empty?
|
|
1429
|
+
|
|
1430
|
+
reserved_keys = %w[name instructions number status parent added_by sub_steps sub-steps]
|
|
1431
|
+
step_config.each_with_object({}) do |(key, value), memo|
|
|
1432
|
+
key_str = key.to_s
|
|
1433
|
+
next if reserved_keys.include?(key_str)
|
|
1434
|
+
memo[key_str] = value
|
|
1435
|
+
end
|
|
1436
|
+
end
|
|
1437
|
+
|
|
1438
|
+
def default_dynamic_step_instructions
|
|
1439
|
+
DEFAULT_DYNAMIC_STEP_INSTRUCTIONS
|
|
1440
|
+
end
|
|
1441
|
+
|
|
1122
1442
|
def find_target_step_for_start(state, step_number, fork_root)
|
|
1123
1443
|
target = state.find_by_number(step_number)
|
|
1124
1444
|
raise StepErrors::NotFound, "Step #{step_number} not found in queue" unless target
|
|
@@ -1264,7 +1584,11 @@ module Ace
|
|
|
1264
1584
|
if after
|
|
1265
1585
|
if as_child
|
|
1266
1586
|
# Insert as first child of 'after'
|
|
1267
|
-
|
|
1587
|
+
begin
|
|
1588
|
+
new_number = Atoms::StepNumbering.next_child(after, existing_numbers)
|
|
1589
|
+
rescue ArgumentError => e
|
|
1590
|
+
raise Error, e.message
|
|
1591
|
+
end
|
|
1268
1592
|
[new_number, []]
|
|
1269
1593
|
else
|
|
1270
1594
|
# Insert as sibling after 'after'
|