ace-assign 0.37.0 → 0.40.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.
- 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 +132 -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 +39 -18
|
@@ -74,7 +74,8 @@ If input is an existing `.yml`/`.yaml` file path:
|
|
|
74
74
|
|
|
75
75
|
If input is an exact preset/recipe-style request (for example `work-on-task --taskref 123`):
|
|
76
76
|
- Run `wfi://assign/prepare` to produce normalized job content
|
|
77
|
-
-
|
|
77
|
+
- If prepare reports all requested refs are already terminal (`done/skipped/cancelled`), stop and return that no assignment was created
|
|
78
|
+
- Otherwise continue to hidden-spec rendering (step 4) using the filtered ref set from prepare
|
|
78
79
|
|
|
79
80
|
#### Path C: Explicit Step or Freeform Intent (Default)
|
|
80
81
|
|
|
@@ -100,7 +101,11 @@ For unmatched phrases:
|
|
|
100
101
|
Skill-backed steps (for example `work-on-task`) stay high-level in rendered YAML.
|
|
101
102
|
Runtime `ace-assign create` will materialize `assign.source` sub-steps deterministically.
|
|
102
103
|
|
|
103
|
-
### 4. Render Hidden Spec (Paths B/C)
|
|
104
|
+
### 4. Render Hidden Spec (Paths B/C with Workable Input)
|
|
105
|
+
|
|
106
|
+
Precondition:
|
|
107
|
+
- Path B: prepare returned workable, filtered content (not an all-terminal abort)
|
|
108
|
+
- Path C: compose resolved at least one actionable step
|
|
104
109
|
|
|
105
110
|
Create hidden spec directory if missing:
|
|
106
111
|
|
|
@@ -133,6 +138,7 @@ steps:
|
|
|
133
138
|
Rules:
|
|
134
139
|
- Each invocation writes a new file (no in-place mutation of prior hidden specs).
|
|
135
140
|
- Hidden specs are internal provenance artifacts; users are not required to edit them.
|
|
141
|
+
- Do not render a hidden spec for all-terminal `work-on-task` requests that aborted in Path B.
|
|
136
142
|
|
|
137
143
|
### 5. Create Assignment Deterministically
|
|
138
144
|
|
|
@@ -170,6 +176,7 @@ Step 010: ...
|
|
|
170
176
|
|----------|--------|
|
|
171
177
|
| Unknown explicit phrase | Return unmatched phrase + closest catalog/skill candidates; no assignment created |
|
|
172
178
|
| Conflicting explicit order | Reorder only by hard rule and report the named rule that required it |
|
|
179
|
+
| Path B all requested refs already terminal | Return clear no-op result (`All requested tasks are already terminal (done/skipped/cancelled): ...`, `No assignment created.`); skip hidden-spec render and `ace-assign create` |
|
|
173
180
|
| Hidden-spec render failure | Return concrete render error; no assignment created |
|
|
174
181
|
| `ace-assign create` rejection | Surface CLI error unchanged |
|
|
175
182
|
| `--run` requested but no workable step | Keep create success; report why drive did not continue |
|
|
@@ -179,6 +186,8 @@ Step 010: ...
|
|
|
179
186
|
- Re-running the same request creates a new hidden spec file.
|
|
180
187
|
- Explicit duplicate steps are normalized unless repetition is clearly requested.
|
|
181
188
|
- Explicit steps take precedence over recipe defaults when both are present.
|
|
189
|
+
- Path B mixed refs (terminal + non-terminal) continue with filtered non-terminal refs only.
|
|
190
|
+
- Path B all-terminal refs produce no assignment and no hidden-spec artifact.
|
|
182
191
|
- High-level skill-backed steps may expand into sub-steps at create runtime via `assign.source` metadata.
|
|
183
192
|
- `--run` is a workflow-level create-then-drive handoff, not natural-language parsing in `ace-assign create`.
|
|
184
193
|
- Quiet mode for `ace-assign create` suppresses non-essential output (including provenance line).
|
|
@@ -187,6 +196,8 @@ Step 010: ...
|
|
|
187
196
|
|
|
188
197
|
- Hidden spec is written under `.ace-local/assign/jobs/` for generated inputs
|
|
189
198
|
- `ace-assign create FILE` receives the rendered spec path
|
|
199
|
+
- Path B all-terminal requests do not render a hidden spec and do not call `ace-assign create`
|
|
200
|
+
- Path B mixed requests render/create from filtered non-terminal refs only
|
|
190
201
|
- Assignment metadata preserves hidden-spec provenance
|
|
191
202
|
- Explicit step requests map to expected steps with explainable ordering
|
|
192
203
|
- Capability skills remain excluded from assign composition
|
|
@@ -197,7 +208,7 @@ Step 010: ...
|
|
|
197
208
|
|
|
198
209
|
```bash
|
|
199
210
|
# Validate intent-resolution language exists in create workflow
|
|
200
|
-
rg -n "explicit|phrase|advisory|assign.source|--run" ace-assign/handbook/workflow-instructions/assign/create.wf.md
|
|
211
|
+
rg -n "explicit|phrase|advisory|assign.source|--run|already terminal|No assignment created" ace-assign/handbook/workflow-instructions/assign/create.wf.md
|
|
201
212
|
|
|
202
213
|
# Validate hidden-spec references remain present
|
|
203
214
|
rg -n "\.ace-local/assign/jobs|Created from hidden spec" ace-assign
|
|
@@ -212,4 +223,4 @@ After assignment creation:
|
|
|
212
223
|
|
|
213
224
|
```bash
|
|
214
225
|
/as-assign-drive <assignment-id>
|
|
215
|
-
```
|
|
226
|
+
```
|
|
@@ -11,7 +11,7 @@ ace-docs:
|
|
|
11
11
|
|
|
12
12
|
## Purpose
|
|
13
13
|
|
|
14
|
-
Transform informal instructions OR preset names into a structured job.yaml file that can be used with `ace-assign create job.yaml`. This workflow bridges the gap between high-level intent and the structured work queue format.
|
|
14
|
+
Transform informal instructions OR preset names into a structured job.yaml file that can be used with `ace-assign create --yaml job.yaml`. This workflow bridges the gap between high-level intent and the structured work queue format.
|
|
15
15
|
|
|
16
16
|
## Input Formats
|
|
17
17
|
|
|
@@ -227,6 +227,38 @@ From informal instructions:
|
|
|
227
227
|
- "tasks 148, 149, 150" → `taskrefs: ["148", "149", "150"]`
|
|
228
228
|
- "PR 45" → `pr_number: "45"`
|
|
229
229
|
|
|
230
|
+
### 3.1 Resolve Requested Taskrefs and Filter Terminal Tasks (`work-on-task` only)
|
|
231
|
+
|
|
232
|
+
Apply this step when preparing the `work-on-task` preset.
|
|
233
|
+
|
|
234
|
+
1. Resolve requested refs to concrete task refs first (before any filtering):
|
|
235
|
+
- `--taskref` single value
|
|
236
|
+
- comma-separated `--taskrefs`
|
|
237
|
+
- ranges like `148-152`
|
|
238
|
+
- patterns like `240.*`
|
|
239
|
+
2. For each resolved ref, run:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
ace-task show <taskref>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
3. Parse the reported status using `Ace::Task::Atoms::TaskValidationRules::TERMINAL_STATUSES` as the source of truth:
|
|
246
|
+
- If status is terminal (`done`, `skipped`, `cancelled`) → add to `skipped_terminal_refs`
|
|
247
|
+
- Otherwise (`pending`, `draft`, `in-progress`, `blocked`) → keep in `effective_taskrefs`
|
|
248
|
+
4. Preserve existing invalid-ref behavior:
|
|
249
|
+
- If a ref cannot be resolved/found, fail with the existing invalid task reference path.
|
|
250
|
+
5. Branch by result:
|
|
251
|
+
- Mixed set (`effective_taskrefs` non-empty, `skipped_terminal_refs` non-empty):
|
|
252
|
+
- Continue with `effective_taskrefs`
|
|
253
|
+
- Report skipped refs clearly (example: `Skipped terminal tasks (done/skipped/cancelled): 149`)
|
|
254
|
+
- All-terminal set (`effective_taskrefs` empty, `skipped_terminal_refs` non-empty):
|
|
255
|
+
- Stop before preset expansion and before job generation
|
|
256
|
+
- Report:
|
|
257
|
+
- `All requested tasks are already terminal (done/skipped/cancelled): <refs>`
|
|
258
|
+
- `No assignment created.`
|
|
259
|
+
|
|
260
|
+
`effective_taskrefs` is now the source of truth for downstream expansion, hidden spec rendering, and mark-tasks-done behavior.
|
|
261
|
+
|
|
230
262
|
### 4. Apply Customizations (if prose provided)
|
|
231
263
|
|
|
232
264
|
Parse modifications:
|
|
@@ -246,6 +278,8 @@ params = { "taskrefs" => ["148", "149", "150"], "review_preset" => "batch" }
|
|
|
246
278
|
steps = Ace::Assign::Atoms::PresetExpander.expand(preset, params)
|
|
247
279
|
```
|
|
248
280
|
|
|
281
|
+
For `work-on-task`, pass `effective_taskrefs` from step 3.1 (not the unfiltered requested list).
|
|
282
|
+
|
|
249
283
|
### 5.1 Resolve Skill `assign.source` Metadata (Deterministic Runtime Expansion)
|
|
250
284
|
|
|
251
285
|
After preset expansion, each step with a `skill:` field may declare assignment source metadata via the skill frontmatter:
|
|
@@ -331,7 +365,7 @@ Steps: 10 total
|
|
|
331
365
|
- apply-feedback-3
|
|
332
366
|
- finalize
|
|
333
367
|
|
|
334
|
-
Start assignment with: ace-assign create job.yaml
|
|
368
|
+
Start assignment with: ace-assign create --yaml job.yaml
|
|
335
369
|
```
|
|
336
370
|
|
|
337
371
|
## Output Format
|
|
@@ -373,6 +407,8 @@ steps:
|
|
|
373
407
|
| Unknown preset | List available presets, ask for selection |
|
|
374
408
|
| Missing required parameter | Prompt for value |
|
|
375
409
|
| Invalid task reference | Show expected formats |
|
|
410
|
+
| Mixed refs include terminal tasks | Skip terminal refs, continue with remaining refs, and report skipped refs |
|
|
411
|
+
| All requested refs are terminal | Stop before expansion/job generation and report: `All requested tasks are already terminal (done/skipped/cancelled): ...` + `No assignment created.` |
|
|
376
412
|
| Unresolved placeholders | Report which parameters need values |
|
|
377
413
|
|
|
378
414
|
## Examples
|
|
@@ -437,10 +473,35 @@ Expands range to tasks 148, 149, 150, 151, 152.
|
|
|
437
473
|
|
|
438
474
|
Expands pattern to match subtasks (requires resolution at prepare time).
|
|
439
475
|
|
|
476
|
+
### Example 7: Mixed Set with Terminal Tasks
|
|
477
|
+
|
|
478
|
+
```
|
|
479
|
+
/as-assign-prepare work-on-task --taskrefs 148,149,150
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
If `149` is terminal (for example `done`):
|
|
483
|
+
- continue with `148,150`
|
|
484
|
+
- report `Skipped terminal tasks (done/skipped/cancelled): 149`
|
|
485
|
+
|
|
486
|
+
### Example 8: All Requested Tasks Already Terminal
|
|
487
|
+
|
|
488
|
+
```
|
|
489
|
+
/as-assign-prepare work-on-task --taskrefs 148,149
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
If both are terminal:
|
|
493
|
+
- report `All requested tasks are already terminal (done/skipped/cancelled): 148,149`
|
|
494
|
+
- report `No assignment created.`
|
|
495
|
+
- do not generate a job.yaml
|
|
496
|
+
|
|
440
497
|
## Success Criteria
|
|
441
498
|
|
|
442
499
|
- [ ] Input correctly parsed (preset, parameters, or prose)
|
|
443
500
|
- [ ] Preset loaded and parameters injected
|
|
501
|
+
- [ ] Requested refs resolve to concrete task refs before any terminal-status filtering
|
|
502
|
+
- [ ] Terminal filtering uses `Ace::Task::Atoms::TaskValidationRules::TERMINAL_STATUSES`
|
|
503
|
+
- [ ] Mixed requested sets continue with non-terminal refs and report skipped terminal refs
|
|
504
|
+
- [ ] All-terminal requested sets stop before queue/job creation
|
|
444
505
|
- [ ] Expansion directives processed (if present)
|
|
445
506
|
- [ ] Hierarchical steps numbered correctly
|
|
446
507
|
- [ ] Loops expanded into separate steps
|
|
@@ -457,7 +518,7 @@ After job.yaml is created:
|
|
|
457
518
|
/as-assign-create job.yaml
|
|
458
519
|
|
|
459
520
|
# Or directly:
|
|
460
|
-
ace-assign create job.yaml
|
|
521
|
+
ace-assign create --yaml job.yaml
|
|
461
522
|
|
|
462
523
|
# Check status
|
|
463
524
|
ace-assign status
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Atoms
|
|
8
|
+
# Loads assign presets from project overrides first, then gem defaults.
|
|
9
|
+
module PresetLoader
|
|
10
|
+
VALID_PRESET_NAME = /\A[A-Za-z0-9_-]+\z/.freeze
|
|
11
|
+
|
|
12
|
+
def self.load(preset_name)
|
|
13
|
+
name = preset_name.to_s.strip
|
|
14
|
+
raise Ace::Support::Cli::Error, "Preset name cannot be empty" if name.empty?
|
|
15
|
+
unless VALID_PRESET_NAME.match?(name)
|
|
16
|
+
raise Ace::Support::Cli::Error,
|
|
17
|
+
"Invalid preset name '#{name}'. Allowed characters: letters, numbers, underscore, hyphen."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
path = resolve_path(name)
|
|
21
|
+
raise Ace::Support::Cli::Error, "Preset '#{name}' not found" unless path
|
|
22
|
+
|
|
23
|
+
data = YAML.safe_load_file(path, aliases: true)
|
|
24
|
+
unless data.is_a?(Hash)
|
|
25
|
+
raise Ace::Support::Cli::Error, "Preset '#{name}' is invalid"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
data
|
|
29
|
+
rescue Psych::SyntaxError => e
|
|
30
|
+
raise Ace::Support::Cli::Error, "Invalid YAML in preset '#{name}': #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.resolve_path(name)
|
|
34
|
+
project_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
35
|
+
gem_root = Gem.loaded_specs["ace-assign"]&.gem_dir || File.expand_path("../../../..", __dir__)
|
|
36
|
+
|
|
37
|
+
project_path = File.join(project_root, ".ace", "assign", "presets", "#{name}.yml")
|
|
38
|
+
default_path = File.join(gem_root, ".ace-defaults", "assign", "presets", "#{name}.yml")
|
|
39
|
+
|
|
40
|
+
return project_path if File.exist?(project_path)
|
|
41
|
+
return default_path if File.exist?(default_path)
|
|
42
|
+
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
private_class_method :resolve_path
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Atoms
|
|
6
|
+
# Resolves preset step definitions by exact or base-name matching.
|
|
7
|
+
module PresetStepResolver
|
|
8
|
+
ITERATION_SUFFIX_REGEX = /-\d+\z/.freeze
|
|
9
|
+
|
|
10
|
+
def self.find_steps(preset, names)
|
|
11
|
+
Array(names).map { |name| find_step(preset, name) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.find_step(preset, name)
|
|
15
|
+
requested = name.to_s.strip
|
|
16
|
+
raise Ace::Support::Cli::Error, "Step name cannot be empty" if requested.empty?
|
|
17
|
+
|
|
18
|
+
steps = Array(preset["steps"])
|
|
19
|
+
if steps.empty?
|
|
20
|
+
raise Ace::Support::Cli::Error,
|
|
21
|
+
"Preset '#{preset['name'] || 'unknown'}' has no steps defined. Add a non-empty 'steps' array."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
exact = steps.find { |step| step_name(step) == requested }
|
|
25
|
+
return exact if exact
|
|
26
|
+
|
|
27
|
+
base = base_name(requested)
|
|
28
|
+
matched = steps.find { |step| base_name(step_name(step)) == base }
|
|
29
|
+
return matched if matched
|
|
30
|
+
|
|
31
|
+
available = available_names(steps)
|
|
32
|
+
raise Ace::Support::Cli::Error,
|
|
33
|
+
"Step '#{requested}' not found in preset '#{preset['name'] || 'unknown'}'. " \
|
|
34
|
+
"Available: #{available.join(', ')}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.base_name(name)
|
|
38
|
+
name.to_s.sub(ITERATION_SUFFIX_REGEX, "")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.iteration_name?(name)
|
|
42
|
+
name.to_s.match?(ITERATION_SUFFIX_REGEX)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.next_iteration_name(base, existing_names)
|
|
46
|
+
stem = base_name(base)
|
|
47
|
+
existing = Array(existing_names).map(&:to_s)
|
|
48
|
+
numbers = existing.filter_map do |name|
|
|
49
|
+
match = name.match(/\A#{Regexp.escape(stem)}-(\d+)\z/)
|
|
50
|
+
match && match[1].to_i
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
next_number = numbers.empty? ? 1 : numbers.max + 1
|
|
54
|
+
"#{stem}-#{next_number}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.step_name(step)
|
|
58
|
+
step.is_a?(Hash) ? step["name"].to_s : ""
|
|
59
|
+
end
|
|
60
|
+
private_class_method :step_name
|
|
61
|
+
|
|
62
|
+
def self.available_names(steps)
|
|
63
|
+
names = steps.map { |step| step_name(step) }.reject(&:empty?)
|
|
64
|
+
(names + names.map { |name| base_name(name) }).uniq.sort
|
|
65
|
+
end
|
|
66
|
+
private_class_method :available_names
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -1,100 +1,304 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ace/task"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
3
7
|
module Ace
|
|
4
8
|
module Assign
|
|
5
9
|
module CLI
|
|
6
10
|
module Commands
|
|
7
|
-
# Add a
|
|
8
|
-
#
|
|
9
|
-
# Supports hierarchical step injection:
|
|
10
|
-
# - Default: adds after current/last done step
|
|
11
|
-
# - --after: adds as sibling after specified step
|
|
12
|
-
# - --after + --child: adds as child of specified step
|
|
13
|
-
#
|
|
14
|
-
# @example Add step after current
|
|
15
|
-
# ace-assign add fix-tests -i "Fix the failing tests"
|
|
16
|
-
#
|
|
17
|
-
# @example Inject sibling after specific step
|
|
18
|
-
# ace-assign add verify --after 010 -i "Verify initialization"
|
|
19
|
-
# # Creates 011-verify.st.md (renumbers existing 011+ if needed)
|
|
20
|
-
#
|
|
21
|
-
# @example Inject child step
|
|
22
|
-
# ace-assign add verify --after 010 --child -i "Verify"
|
|
23
|
-
# # Creates 010.01-verify.st.md
|
|
11
|
+
# Add step trees dynamically to a running assignment.
|
|
24
12
|
class Add < Ace::Support::Cli::Command
|
|
25
13
|
include Ace::Support::Cli::Base
|
|
26
14
|
include AssignmentTarget
|
|
27
15
|
|
|
28
|
-
desc "Add
|
|
16
|
+
desc "Add step(s) to the queue dynamically"
|
|
29
17
|
|
|
30
|
-
|
|
31
|
-
option :
|
|
18
|
+
option :yaml, desc: "Path to YAML file with a top-level steps: array"
|
|
19
|
+
option :step, desc: "Preset step name(s), comma-separated"
|
|
20
|
+
option :task, desc: "Task reference to expand from preset child-template"
|
|
21
|
+
option :preset, desc: "Preset name (required only for --step/--task overrides)"
|
|
32
22
|
option :after, aliases: ["-a"], desc: "Insert after this step number (e.g., 010)"
|
|
33
23
|
option :child, aliases: ["-c"], type: :boolean, default: false, desc: "Insert as child of --after step"
|
|
34
24
|
option :assignment, desc: "Target specific assignment ID"
|
|
35
25
|
option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
|
|
36
26
|
option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
|
|
37
27
|
|
|
38
|
-
def call(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
def call(**options)
|
|
29
|
+
validate_mode_options!(options)
|
|
30
|
+
validate_child_requires_after!(options)
|
|
31
|
+
|
|
32
|
+
target = resolve_assignment_target(options)
|
|
33
|
+
executor = build_executor_for_target(target)
|
|
34
|
+
|
|
35
|
+
mode = selected_mode(options)
|
|
36
|
+
case mode
|
|
37
|
+
when :yaml
|
|
38
|
+
handle_yaml_mode(executor, options)
|
|
39
|
+
when :step
|
|
40
|
+
handle_step_mode(executor, options)
|
|
41
|
+
when :task
|
|
42
|
+
handle_task_mode(executor, options)
|
|
43
|
+
else
|
|
44
|
+
raise Ace::Support::Cli::Error, "Exactly one of --yaml, --step, or --task is required"
|
|
42
45
|
end
|
|
46
|
+
end
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def validate_mode_options!(options)
|
|
51
|
+
selected = insertion_modes(options).count { |_, value| !value.to_s.strip.empty? }
|
|
52
|
+
raise Ace::Support::Cli::Error, "Exactly one of --yaml, --step, or --task is required" if selected.zero?
|
|
53
|
+
raise Ace::Support::Cli::Error, "--yaml, --step, and --task are mutually exclusive" if selected > 1
|
|
54
|
+
|
|
55
|
+
preset = options[:preset].to_s.strip
|
|
56
|
+
return if preset.empty?
|
|
57
|
+
return if !options[:step].to_s.strip.empty? || !options[:task].to_s.strip.empty?
|
|
58
|
+
|
|
59
|
+
raise Ace::Support::Cli::Error, "--preset requires --step or --task"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_child_requires_after!(options)
|
|
63
|
+
if options[:child] && options[:after].to_s.strip.empty? && options[:task].to_s.strip.empty?
|
|
64
|
+
raise Ace::Support::Cli::Error, "--child requires --after to specify the parent step"
|
|
53
65
|
end
|
|
66
|
+
end
|
|
54
67
|
|
|
55
|
-
|
|
68
|
+
def insertion_modes(options)
|
|
69
|
+
{
|
|
70
|
+
yaml: options[:yaml],
|
|
71
|
+
step: options[:step],
|
|
72
|
+
task: options[:task]
|
|
73
|
+
}
|
|
74
|
+
end
|
|
56
75
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
def selected_mode(options)
|
|
77
|
+
insertion_modes(options).find { |_, value| !value.to_s.strip.empty? }&.first
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def handle_yaml_mode(executor, options)
|
|
81
|
+
yaml_path = options[:yaml].to_s.strip
|
|
82
|
+
steps = load_steps_from_file(yaml_path)
|
|
83
|
+
result = executor.add_batch(
|
|
84
|
+
steps: steps,
|
|
62
85
|
after: options[:after],
|
|
63
|
-
as_child: options[:child]
|
|
86
|
+
as_child: options[:child],
|
|
87
|
+
source_file: yaml_path
|
|
64
88
|
)
|
|
89
|
+
print_yaml_result(result, yaml_path) unless options[:quiet]
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
65
92
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
93
|
+
def handle_step_mode(executor, options)
|
|
94
|
+
preset_name = resolve_preset_name(executor, options)
|
|
95
|
+
preset = Atoms::PresetLoader.load(preset_name)
|
|
96
|
+
requested = parse_step_names(options[:step])
|
|
97
|
+
templates = Atoms::PresetStepResolver.find_steps(preset, requested)
|
|
98
|
+
|
|
99
|
+
steps = []
|
|
100
|
+
added_steps = []
|
|
101
|
+
renumbered = []
|
|
102
|
+
sibling_cursor = options[:after]
|
|
103
|
+
|
|
104
|
+
templates.each do |template|
|
|
105
|
+
state = executor.status[:state]
|
|
106
|
+
existing_names = state.steps.map(&:name)
|
|
107
|
+
step = build_preset_step(template, existing_names: existing_names)
|
|
108
|
+
steps << step
|
|
109
|
+
|
|
110
|
+
inserted = executor.add_batch(
|
|
111
|
+
steps: [step],
|
|
112
|
+
after: options[:child] ? options[:after] : sibling_cursor,
|
|
113
|
+
as_child: options[:child],
|
|
114
|
+
source_file: "preset:#{preset_name}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
inserted_added = Array(inserted[:added])
|
|
118
|
+
added_steps.concat(inserted_added)
|
|
119
|
+
renumbered.concat(Array(inserted[:renumbered]))
|
|
120
|
+
sibling_cursor = inserted_added.first&.number unless options[:child]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
result = {added: added_steps, renumbered: renumbered.uniq}
|
|
124
|
+
|
|
125
|
+
print_step_result(result, steps, after: options[:after]) unless options[:quiet]
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def handle_task_mode(executor, options)
|
|
130
|
+
preset_name = resolve_preset_name(executor, options)
|
|
131
|
+
preset = Atoms::PresetLoader.load(preset_name)
|
|
132
|
+
task_ref = options[:task].to_s.strip
|
|
133
|
+
raise Ace::Support::Cli::Error, "--task requires a task reference" if task_ref.empty?
|
|
134
|
+
raise Ace::Support::Cli::Error, "Task not found: #{task_ref}" unless task_manager.show(task_ref)
|
|
79
135
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
136
|
+
child_template = preset.dig("expansion", "child-template")
|
|
137
|
+
unless child_template.is_a?(Hash)
|
|
138
|
+
raise Ace::Support::Cli::Error, "Preset '#{preset_name}' has no expansion.child-template"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
parent_step = options[:after].to_s.strip
|
|
142
|
+
parent_step = detect_batch_parent(executor) if parent_step.empty?
|
|
143
|
+
if parent_step.to_s.strip.empty?
|
|
144
|
+
raise Ace::Support::Cli::Error, "No batch parent found. Pass --after <step> to specify."
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
task_step = build_task_step(child_template, task_ref, debug: options[:debug])
|
|
148
|
+
insert_as_child = options[:child] || options[:after].to_s.strip.empty?
|
|
149
|
+
result = executor.add_batch(
|
|
150
|
+
steps: [task_step],
|
|
151
|
+
after: parent_step,
|
|
152
|
+
as_child: insert_as_child,
|
|
153
|
+
source_file: "preset:#{preset_name}:task:#{task_ref}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
print_task_result(result, task_ref, parent_step, as_child: insert_as_child) unless options[:quiet]
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def resolve_preset_name(executor, options)
|
|
161
|
+
explicit = options[:preset].to_s.strip
|
|
162
|
+
return explicit unless explicit.empty?
|
|
163
|
+
|
|
164
|
+
assignment = executor.status[:assignment]
|
|
165
|
+
Molecules::PresetInferrer.infer_from_assignment(assignment)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def parse_step_names(raw)
|
|
169
|
+
names = raw.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
170
|
+
raise Ace::Support::Cli::Error, "--step requires at least one step name" if names.empty?
|
|
171
|
+
|
|
172
|
+
names
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_preset_step(template, existing_names:)
|
|
176
|
+
step = deep_dup_hash(template)
|
|
177
|
+
step_name = step["name"].to_s
|
|
178
|
+
if Atoms::PresetStepResolver.iteration_name?(step_name)
|
|
179
|
+
next_name = Atoms::PresetStepResolver.next_iteration_name(
|
|
180
|
+
Atoms::PresetStepResolver.base_name(step_name),
|
|
181
|
+
existing_names
|
|
182
|
+
)
|
|
183
|
+
step["name"] = next_name
|
|
184
|
+
existing_names << next_name
|
|
185
|
+
else
|
|
186
|
+
existing_names << step_name
|
|
187
|
+
end
|
|
188
|
+
step
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def build_task_step(template, task_ref, debug: false)
|
|
192
|
+
normalized = template.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
|
|
193
|
+
step = substitute_tokens(normalized, "item" => task_ref)
|
|
194
|
+
warn_unexpanded_template_tokens(step) if debug
|
|
195
|
+
step
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def substitute_tokens(value, replacements)
|
|
199
|
+
case value
|
|
200
|
+
when Hash
|
|
201
|
+
value.each_with_object({}) do |(key, nested), memo|
|
|
202
|
+
memo[key] = substitute_tokens(nested, replacements)
|
|
87
203
|
end
|
|
204
|
+
when Array
|
|
205
|
+
value.map { |item| substitute_tokens(item, replacements) }
|
|
206
|
+
when String
|
|
207
|
+
value.gsub(/\{\{([a-zA-Z0-9_]+)\}\}/) do
|
|
208
|
+
replacements.fetch(Regexp.last_match(1), Regexp.last_match(0))
|
|
209
|
+
end
|
|
210
|
+
else
|
|
211
|
+
value
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def warn_unexpanded_template_tokens(value)
|
|
216
|
+
tokens = collect_template_tokens(value)
|
|
217
|
+
return if tokens.empty?
|
|
88
218
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
219
|
+
warn "[ace-assign] Warning: Unexpanded preset template token(s): " \
|
|
220
|
+
"#{tokens.join(', ')}. Supported tokens for --task mode: {{item}}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def collect_template_tokens(value, tokens = Set.new)
|
|
224
|
+
case value
|
|
225
|
+
when Hash
|
|
226
|
+
value.each_value { |nested| collect_template_tokens(nested, tokens) }
|
|
227
|
+
when Array
|
|
228
|
+
value.each { |item| collect_template_tokens(item, tokens) }
|
|
229
|
+
when String
|
|
230
|
+
value.scan(/\{\{([a-zA-Z0-9_]+)\}\}/) do |match|
|
|
231
|
+
tokens << "{{#{match.first}}}"
|
|
93
232
|
end
|
|
94
233
|
end
|
|
234
|
+
|
|
235
|
+
tokens.to_a.sort
|
|
95
236
|
end
|
|
96
237
|
|
|
97
|
-
|
|
238
|
+
def detect_batch_parent(executor)
|
|
239
|
+
state = executor.status[:state]
|
|
240
|
+
|
|
241
|
+
batch_parent = state.top_level.find { |step| step.name == "batch-tasks" }
|
|
242
|
+
return batch_parent.number if batch_parent
|
|
243
|
+
|
|
244
|
+
fallback = state.top_level.find do |step|
|
|
245
|
+
state.children_of(step.number).any? { |child| child.name.start_with?("work-on-") }
|
|
246
|
+
end
|
|
247
|
+
fallback&.number
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def task_manager
|
|
251
|
+
@task_manager ||= Ace::Task::Organisms::TaskManager.new
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def load_steps_from_file(path)
|
|
255
|
+
raise Ace::Support::Cli::Error, "File not found: #{path}" unless File.exist?(path)
|
|
256
|
+
|
|
257
|
+
begin
|
|
258
|
+
data = YAML.safe_load_file(path, aliases: true)
|
|
259
|
+
rescue Psych::SyntaxError => e
|
|
260
|
+
raise Ace::Support::Cli::Error, "Invalid YAML in #{path}: #{e.message}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
steps = data.is_a?(Hash) ? data["steps"] : nil
|
|
264
|
+
unless steps.is_a?(Array) && steps.any?
|
|
265
|
+
raise Ace::Support::Cli::Error, "No steps defined in #{path}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
steps
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def deep_dup_hash(value)
|
|
272
|
+
Marshal.load(Marshal.dump(value))
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def print_yaml_result(result, source_path)
|
|
276
|
+
added_steps = Array(result[:added])
|
|
277
|
+
puts "Added #{added_steps.size} step(s) from #{File.basename(source_path)}"
|
|
278
|
+
print_added_steps(added_steps)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def print_step_result(result, requested_steps, after: nil)
|
|
282
|
+
added_steps = Array(result[:added])
|
|
283
|
+
root_name = requested_steps.first["name"]
|
|
284
|
+
relation = after.to_s.strip.empty? ? "" : " after #{after}"
|
|
285
|
+
puts "Added #{root_name} (#{added_steps.size} step(s))#{relation}".strip
|
|
286
|
+
print_added_steps(added_steps)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def print_task_result(result, task_ref, parent_step, as_child:)
|
|
290
|
+
added_steps = Array(result[:added])
|
|
291
|
+
relation = as_child ? "under" : "after"
|
|
292
|
+
puts "Added task #{task_ref} #{relation} #{parent_step}"
|
|
293
|
+
print_added_steps(added_steps)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def print_added_steps(steps)
|
|
297
|
+
steps.each do |step|
|
|
298
|
+
fork_suffix = step.context == "fork" ? " (fork)" : ""
|
|
299
|
+
puts " #{step.number}: #{step.name} [#{step.status}]#{fork_suffix}"
|
|
300
|
+
end
|
|
301
|
+
end
|
|
98
302
|
end
|
|
99
303
|
end
|
|
100
304
|
end
|