ace-design 0.2.0
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 +7 -0
- data/.ace-defaults/design/config.yml +9 -0
- data/.ace-defaults/design/contexts/app/component.md +3 -0
- data/.ace-defaults/design/contexts/app/view.md +3 -0
- data/.ace-defaults/design/contexts/app.md +3 -0
- data/.ace-defaults/design/contexts/project.md +7 -0
- data/.ace-defaults/design/contexts/site/component.md +3 -0
- data/.ace-defaults/design/contexts/site/view.md +3 -0
- data/.ace-defaults/design/contexts/site.md +3 -0
- data/.ace-defaults/design/presets/ace-vs-taste.yml +37 -0
- data/.ace-defaults/design/presets/fast.yml +18 -0
- data/.ace-defaults/design/presets/impeccable.yml +48 -0
- data/.ace-defaults/design/presets/superdesign.yml +48 -0
- data/.ace-defaults/design/presets/taste-v2.yml +46 -0
- data/.ace-defaults/design/presets/ui-ux-pro-max.yml +48 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-design.yml +8 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-design.yml +8 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-design.yml +8 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-design.yml +8 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE +21 -0
- data/README.md +119 -0
- data/Rakefile +12 -0
- data/browser/dependencies.mjs +20 -0
- data/browser/doctor.mjs +7 -0
- data/browser/inspect.mjs +61 -0
- data/browser/package-lock.json +71 -0
- data/browser/package.json +1 -0
- data/browser/preview.mjs +35 -0
- data/browser/source.mjs +14 -0
- data/docs/contexts.md +66 -0
- data/docs/inspiration.md +28 -0
- data/docs/intent-verification.md +73 -0
- data/docs/runner.md +108 -0
- data/docs/testing.md +18 -0
- data/docs/usage.md +38 -0
- data/exe/ace-design +19 -0
- data/handbook/benchmarks/app-booking/brief.md +8 -0
- data/handbook/benchmarks/app-booking/fixtures.json +46 -0
- data/handbook/benchmarks/app-inventory/brief.md +8 -0
- data/handbook/benchmarks/app-inventory/fixtures.json +38 -0
- data/handbook/benchmarks/app-support/brief.md +8 -0
- data/handbook/benchmarks/app-support/fixtures.json +41 -0
- data/handbook/benchmarks/site-community/brief.md +8 -0
- data/handbook/benchmarks/site-community/fixtures.json +41 -0
- data/handbook/benchmarks/site-course/brief.md +8 -0
- data/handbook/benchmarks/site-course/fixtures.json +41 -0
- data/handbook/benchmarks/site-studio/brief.md +8 -0
- data/handbook/benchmarks/site-studio/fixtures.json +38 -0
- data/handbook/catalog/app/error-recovery.md +11 -0
- data/handbook/catalog/app/status-feedback.md +11 -0
- data/handbook/catalog/app/task-first-density.md +11 -0
- data/handbook/catalog/shared/automated-a11y-boundary.md +11 -0
- data/handbook/catalog/shared/bounded-exploration.md +11 -0
- data/handbook/catalog/shared/context-before-style.md +11 -0
- data/handbook/catalog/shared/contextual-cards.md +11 -0
- data/handbook/catalog/shared/focus-visible.md +11 -0
- data/handbook/catalog/shared/responsive-reflow.md +11 -0
- data/handbook/catalog/site/critique-versus-defect.md +11 -0
- data/handbook/catalog/site/purposeful-links.md +11 -0
- data/handbook/catalog/site/subject-specific-hero.md +11 -0
- data/handbook/guides/design/process.g.md +20 -0
- data/handbook/skills/as-design-app/SKILL.md +21 -0
- data/handbook/skills/as-design-app-adjust/SKILL.md +23 -0
- data/handbook/skills/as-design-app-audit/SKILL.md +23 -0
- data/handbook/skills/as-design-app-implement/SKILL.md +22 -0
- data/handbook/skills/as-design-look-for-inspiration/SKILL.md +24 -0
- data/handbook/skills/as-design-site/SKILL.md +21 -0
- data/handbook/skills/as-design-site-adjust/SKILL.md +23 -0
- data/handbook/skills/as-design-site-audit/SKILL.md +23 -0
- data/handbook/skills/as-design-site-implement/SKILL.md +22 -0
- data/handbook/templates/design/benchmark-result.template.md +14 -0
- data/handbook/templates/design/brief.template.md +12 -0
- data/handbook/templates/design/feedback.template.md +8 -0
- data/handbook/templates/design/proposal.template.md +15 -0
- data/handbook/templates/design/source-ledger.template.md +5 -0
- data/handbook/workflow-instructions/design/adjust.wf.md +51 -0
- data/handbook/workflow-instructions/design/audit.wf.md +50 -0
- data/handbook/workflow-instructions/design/explore.wf.md +51 -0
- data/handbook/workflow-instructions/design/implement.wf.md +47 -0
- data/handbook/workflow-instructions/design/look-for-inspiration.wf.md +43 -0
- data/lib/ace/design/atoms/contracts.rb +36 -0
- data/lib/ace/design/cli/commands/adjust.rb +41 -0
- data/lib/ace/design/cli/commands/doctor.rb +18 -0
- data/lib/ace/design/cli/commands/explore.rb +42 -0
- data/lib/ace/design/cli/commands/inspect.rb +21 -0
- data/lib/ace/design/cli/commands/preset.rb +32 -0
- data/lib/ace/design/cli/commands/preview.rb +21 -0
- data/lib/ace/design/cli/commands/run_commands.rb +50 -0
- data/lib/ace/design/cli/commands/validate.rb +25 -0
- data/lib/ace/design/cli.rb +79 -0
- data/lib/ace/design/molecules/adjust_prompts.rb +40 -0
- data/lib/ace/design/molecules/agent_executor.rb +28 -0
- data/lib/ace/design/molecules/artifacts.rb +94 -0
- data/lib/ace/design/molecules/catalog.rb +36 -0
- data/lib/ace/design/molecules/context_document.rb +42 -0
- data/lib/ace/design/molecules/context_repository.rb +117 -0
- data/lib/ace/design/molecules/design_context.rb +96 -0
- data/lib/ace/design/molecules/design_prompts.rb +118 -0
- data/lib/ace/design/molecules/design_source.rb +89 -0
- data/lib/ace/design/molecules/preset.rb +120 -0
- data/lib/ace/design/molecules/runtime.rb +75 -0
- data/lib/ace/design/molecules/session.rb +123 -0
- data/lib/ace/design/molecules/skill_sources.rb +71 -0
- data/lib/ace/design/organisms/adjuster.rb +211 -0
- data/lib/ace/design/organisms/design_run.rb +394 -0
- data/lib/ace/design/organisms/generator.rb +127 -0
- data/lib/ace/design/organisms/inspector.rb +50 -0
- data/lib/ace/design/version.rb +6 -0
- data/lib/ace/design.rb +40 -0
- metadata +280 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "time"
|
|
3
|
+
module Ace
|
|
4
|
+
module Design
|
|
5
|
+
module Organisms
|
|
6
|
+
class Adjuster
|
|
7
|
+
A = Molecules::Artifacts
|
|
8
|
+
C = Molecules::DesignContext
|
|
9
|
+
D = Molecules::ContextDocument
|
|
10
|
+
def initialize(config: Design.config, executor: Molecules::AgentExecutor.new, contexts: C.new)
|
|
11
|
+
@config, @executor, @contexts = config, executor, contexts
|
|
12
|
+
end
|
|
13
|
+
def adjust(surface: nil, kind: nil, profile: nil, prompt: nil, refresh: false, dry_run: false, propose: false, model: nil)
|
|
14
|
+
raise Error, "refresh does not accept --prompt" if prompt && refresh
|
|
15
|
+
raise Error, "Use --dry-run or --propose, not both" if dry_run && propose
|
|
16
|
+
surface, kind = Molecules::DesignSource.scope(surface: surface, kind: kind, profile: profile)
|
|
17
|
+
Atoms::Contracts.text(prompt, "adjust prompt") if prompt
|
|
18
|
+
timeout = Integer(@config.fetch("agent_timeout"))
|
|
19
|
+
raise Error, "agent timeout must be 1..3600 seconds" unless (1..3600).cover?(timeout)
|
|
20
|
+
original = @contexts.repository.read(readonly: dry_run)
|
|
21
|
+
targets = @contexts.targets(original, surface: surface, kind: kind, context: profile)
|
|
22
|
+
# Detect hand edits before any model call, including edits to stale generated documents.
|
|
23
|
+
targets.each { |id| @contexts.read_generated(original, id) if original.key?(C.file(id, "design.md")) }
|
|
24
|
+
proposed = original.dup
|
|
25
|
+
targets.each do |id|
|
|
26
|
+
key = C.file(id, "defaults.md")
|
|
27
|
+
proposed[key] = @contexts.default(id) if refresh || !proposed.key?(key)
|
|
28
|
+
proposed[C.file(id, "user.md")] ||= ""
|
|
29
|
+
end
|
|
30
|
+
target = profile || surface
|
|
31
|
+
if prompt
|
|
32
|
+
key = C.file(target, "user.md")
|
|
33
|
+
proposed[key] += "\n\n## Decision update #{Time.now.utc.iso8601}\n\n#{prompt}\n"
|
|
34
|
+
end
|
|
35
|
+
if dry_run
|
|
36
|
+
return {"status" => "planned", "contexts" => targets, "refresh" => refresh,
|
|
37
|
+
"requested_model" => model || @config.fetch("model"),
|
|
38
|
+
"next" => "Run without --dry-run, or use --propose for an unpublished model composition"}
|
|
39
|
+
end
|
|
40
|
+
run = allocate_run
|
|
41
|
+
warn "Design adjustment: #{run}"
|
|
42
|
+
report = {"status" => "preparing", "report_dir" => run, "contexts" => targets,
|
|
43
|
+
"refresh" => refresh, "propose" => propose, "requested_model" => model || @config.fetch("model")}
|
|
44
|
+
dirty = []
|
|
45
|
+
targets.each do |id|
|
|
46
|
+
parent = id.empty? ? nil : C.chain(id)[-2]
|
|
47
|
+
if parent && dirty.include?(parent)
|
|
48
|
+
dirty << id
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
begin
|
|
52
|
+
@contexts.resolve(proposed, id)
|
|
53
|
+
rescue Error
|
|
54
|
+
dirty << id
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
report["changed_contexts"] = dirty
|
|
58
|
+
inputs = dirty.map do |id|
|
|
59
|
+
parent_id = id.empty? ? nil : C.chain(id)[-2]
|
|
60
|
+
{"id" => id, "parent" => parent_id, "old_defaults" => original[C.file(id, "defaults.md")],
|
|
61
|
+
"parent_design" => parent_id && !dirty.include?(parent_id) ? proposed.fetch(C.file(parent_id, "design.md")) : nil,
|
|
62
|
+
"new_defaults" => proposed.fetch(C.file(id, "defaults.md")),
|
|
63
|
+
"user" => proposed.fetch(C.file(id, "user.md")), "previous_design" => original[C.file(id, "design.md")]}
|
|
64
|
+
end
|
|
65
|
+
A.json(File.join(run, "request.json"), {"contexts" => inputs})
|
|
66
|
+
if dirty.empty?
|
|
67
|
+
report["status"] = "unchanged"
|
|
68
|
+
return save_report(run, report)
|
|
69
|
+
end
|
|
70
|
+
workspace = File.join(run, "worker")
|
|
71
|
+
FileUtils.mkdir_p(workspace)
|
|
72
|
+
FileUtils.cp(File.join(run, "request.json"), workspace)
|
|
73
|
+
File.write(File.join(workspace, "system.md"), Molecules::AdjustPrompts::SYSTEM)
|
|
74
|
+
File.write(File.join(workspace, "prompt.md"), Molecules::AdjustPrompts::PROMPT)
|
|
75
|
+
@executor.validate_model(report.fetch("requested_model"))
|
|
76
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
77
|
+
result = @executor.call(model: report.fetch("requested_model"), prompt: Molecules::AdjustPrompts::PROMPT,
|
|
78
|
+
system: Molecules::AdjustPrompts::SYSTEM, workspace: workspace, timeout: timeout)
|
|
79
|
+
report.merge!(result.slice("execution", "model", "provider", "usage", "metadata"))
|
|
80
|
+
A.json(File.join(workspace, "execution.json"), result)
|
|
81
|
+
raise Error, "Adjustment agent did not complete successfully" unless result.dig("execution", "status") == "succeeded"
|
|
82
|
+
unless !File.symlink?(File.join(workspace, "request.json")) && File.binread(File.join(workspace, "request.json")) == File.binread(File.join(run, "request.json"))
|
|
83
|
+
raise Error, "Adjustment agent modified its inputs"
|
|
84
|
+
end
|
|
85
|
+
output_path = File.join(workspace, "result.json")
|
|
86
|
+
raise Error, "Adjustment result missing or symlink" unless File.file?(output_path) && !File.symlink?(output_path)
|
|
87
|
+
output = validate_output(A.object(output_path), dirty)
|
|
88
|
+
report["conflicts"] = output.fetch("conflicts")
|
|
89
|
+
report["summary"] = output.fetch("contexts").transform_values { |node| node.reject { |key, _| key == "design" } }
|
|
90
|
+
if report["conflicts"].empty?
|
|
91
|
+
dirty.each do |id|
|
|
92
|
+
parent = id.empty? ? nil : proposed.fetch(C.file(C.chain(id)[-2], "design.md"))
|
|
93
|
+
decisions = C.chain(id).to_h { |ancestor| [ancestor, proposed.fetch(C.file(ancestor, "user.md"))] }
|
|
94
|
+
proposed[C.file(id, "design.md")] = D.effective(id: id, defaults: proposed.fetch(C.file(id, "defaults.md")),
|
|
95
|
+
user: proposed.fetch(C.file(id, "user.md")), parent: parent,
|
|
96
|
+
body: output.fetch("contexts").fetch(id).fetch("design"), decisions: decisions)
|
|
97
|
+
end
|
|
98
|
+
targets.each { |id| @contexts.resolve(proposed, id) }
|
|
99
|
+
candidate = write_candidate(run, proposed)
|
|
100
|
+
write_diff(run, original, proposed)
|
|
101
|
+
report["stale_contexts"] = @contexts.ids(proposed).reject do |id|
|
|
102
|
+
@contexts.resolve(proposed, id)
|
|
103
|
+
true
|
|
104
|
+
rescue Error
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
if propose
|
|
108
|
+
report["status"] = "proposed"
|
|
109
|
+
else
|
|
110
|
+
report["status"] = "proposed"
|
|
111
|
+
save_report(run, report)
|
|
112
|
+
@contexts.repository.publish(candidate: candidate, original: original, run: run)
|
|
113
|
+
report["status"] = "published"
|
|
114
|
+
begin
|
|
115
|
+
return save_report(run, report)
|
|
116
|
+
rescue StandardError => error
|
|
117
|
+
warn "Contexts published, but report update failed: #{error.message}"
|
|
118
|
+
return report
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
report["status"] = "conflict"
|
|
123
|
+
end
|
|
124
|
+
save_report(run, report)
|
|
125
|
+
rescue StandardError, Interrupt => error
|
|
126
|
+
if report && run
|
|
127
|
+
report["status"] = error.is_a?(Interrupt) ? "interrupted" : "failed"
|
|
128
|
+
report["error"] = error.message
|
|
129
|
+
begin
|
|
130
|
+
save_report(run, report)
|
|
131
|
+
rescue StandardError
|
|
132
|
+
# Keep the original failure authoritative when diagnostics cannot be saved.
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
raise
|
|
137
|
+
ensure
|
|
138
|
+
if report && started
|
|
139
|
+
report["duration"] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
|
|
140
|
+
begin
|
|
141
|
+
A.json(File.join(run, "report.json"), report)
|
|
142
|
+
rescue StandardError => error
|
|
143
|
+
warn "Could not persist adjustment timing: #{error.message}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
private
|
|
148
|
+
def allocate_run
|
|
149
|
+
base = @contexts.repository.runs
|
|
150
|
+
@contexts.repository.safe_path!(base)
|
|
151
|
+
FileUtils.mkdir_p(base)
|
|
152
|
+
100.times do
|
|
153
|
+
directory = File.join(base, Ace::B36ts.encode(Time.now, format: :ms))
|
|
154
|
+
begin
|
|
155
|
+
Dir.mkdir(directory)
|
|
156
|
+
return directory
|
|
157
|
+
rescue Errno::EEXIST
|
|
158
|
+
sleep 0.002
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
raise Error, "Could not allocate adjustment report"
|
|
162
|
+
end
|
|
163
|
+
def validate_output(output, targets)
|
|
164
|
+
nodes = output["contexts"]
|
|
165
|
+
conflicts = output["conflicts"]
|
|
166
|
+
unless output.keys.sort == %w[conflicts contexts] && nodes.is_a?(Hash) && nodes.keys.sort == targets.sort &&
|
|
167
|
+
conflicts.is_a?(Array) && conflicts.all? { |item| item.is_a?(String) && !item.strip.empty? }
|
|
168
|
+
raise Error, "Adjustment result must contain every context and a conflicts array"
|
|
169
|
+
end
|
|
170
|
+
nodes.each do |id, node|
|
|
171
|
+
unless node.is_a?(Hash) && node.keys.sort == %w[added_rules design omitted_defaults preserved_exceptions]
|
|
172
|
+
raise Error, "Invalid adjustment result for #{id}"
|
|
173
|
+
end
|
|
174
|
+
Atoms::Contracts.text(node["design"], "design for #{id}")
|
|
175
|
+
%w[added_rules preserved_exceptions omitted_defaults].each do |field|
|
|
176
|
+
unless node[field].is_a?(Array) && node[field].all? { |item| item.is_a?(String) }
|
|
177
|
+
raise Error, "Adjustment #{field} must be a text array"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
output
|
|
182
|
+
end
|
|
183
|
+
def write_candidate(run, files)
|
|
184
|
+
candidate = File.join(run, "candidate")
|
|
185
|
+
files.each do |name, content|
|
|
186
|
+
target = File.join(candidate, name)
|
|
187
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
188
|
+
File.binwrite(target, content)
|
|
189
|
+
end
|
|
190
|
+
candidate
|
|
191
|
+
end
|
|
192
|
+
def write_diff(run, before, after)
|
|
193
|
+
text = (before.keys | after.keys).sort.filter_map do |name|
|
|
194
|
+
next if before[name] == after[name]
|
|
195
|
+
old = before.fetch(name, "").lines
|
|
196
|
+
new = after.fetch(name, "").lines
|
|
197
|
+
"--- #{name}\n+++ #{name}\n@@ -1,#{old.size} +1,#{new.size} @@\n" +
|
|
198
|
+
old.map { |line| "-#{line.chomp}\n" }.join + new.map { |line| "+#{line.chomp}\n" }.join
|
|
199
|
+
end.join("\n")
|
|
200
|
+
File.write(File.join(run, "changes.diff"), text)
|
|
201
|
+
end
|
|
202
|
+
def save_report(run, report)
|
|
203
|
+
A.json(File.join(run, "report.json"), report)
|
|
204
|
+
File.write(File.join(run, "report.md"), "# Design adjustment: #{report.fetch('status')}\n\n" +
|
|
205
|
+
"```json\n#{JSON.pretty_generate(report)}\n```\n\nSee changes.diff and worker/result.json when present.\n")
|
|
206
|
+
report
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "time"
|
|
6
|
+
module Ace
|
|
7
|
+
module Design
|
|
8
|
+
module Organisms
|
|
9
|
+
class DesignRun
|
|
10
|
+
A = Molecules::Artifacts
|
|
11
|
+
def initialize(root, executor: Molecules::AgentExecutor.new)
|
|
12
|
+
@root = File.realpath(root)
|
|
13
|
+
@executor = executor
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
reload
|
|
16
|
+
end
|
|
17
|
+
def status
|
|
18
|
+
state = A.object(path("run.json"))
|
|
19
|
+
state.merge("id" => File.basename(@root), "next" => "ace-design preview #{@root}", "session" => @root, "gallery" => File.file?(path("gallery.html")) ? path("gallery.html") : nil)
|
|
20
|
+
end
|
|
21
|
+
def resume
|
|
22
|
+
locked do
|
|
23
|
+
verify_inputs!
|
|
24
|
+
verify_published!
|
|
25
|
+
unless @state.fetch("preparation")["status"] == "succeeded"
|
|
26
|
+
prepare
|
|
27
|
+
end
|
|
28
|
+
if @state.fetch("preparation")["status"] == "succeeded"
|
|
29
|
+
pending = @preset.fetch("variants").reject { |s| @state.fetch("workers").fetch(s.fetch("id"))["status"] == "succeeded" }
|
|
30
|
+
Molecules::Session.new(@root).write_gallery
|
|
31
|
+
execute_slots(pending)
|
|
32
|
+
end
|
|
33
|
+
finish
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
def revise(variant:, prompt:)
|
|
37
|
+
Atoms::Contracts.text(prompt, "feedback")
|
|
38
|
+
locked do
|
|
39
|
+
verify_inputs!
|
|
40
|
+
verify_published!
|
|
41
|
+
slot = @preset.fetch("variants").find { |s| s.fetch("id") == variant }
|
|
42
|
+
raise Error, "Unknown variant: #{variant}" unless slot
|
|
43
|
+
raise Error, "Variant #{variant} has no published mockup" unless File.directory?(path("variants/#{variant}"))
|
|
44
|
+
@state["status"] = "running"
|
|
45
|
+
save
|
|
46
|
+
execute_slot(slot, feedback: prompt)
|
|
47
|
+
finish
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
def select(variant:, decisions:)
|
|
51
|
+
locked do
|
|
52
|
+
verify_inputs!
|
|
53
|
+
verify_published!
|
|
54
|
+
session = Molecules::Session.new(@root)
|
|
55
|
+
raise Error, "Select an available variant" unless session.variants.include?(variant)
|
|
56
|
+
unless decisions.is_a?(Array) && !decisions.empty? && decisions.all? { |d| d.is_a?(String) && !d.strip.empty? }
|
|
57
|
+
raise Error, "Provide at least one nonempty --decision"
|
|
58
|
+
end
|
|
59
|
+
selection = {"surface" => @metadata.fetch("surface"), "variant" => variant,
|
|
60
|
+
"fixtures_sha256" => Digest::SHA256.file(path("fixtures.json")).hexdigest,
|
|
61
|
+
"variant_sha256" => A.digest(path("variants/#{variant}")), "decisions" => decisions}
|
|
62
|
+
A.json(path("selection.json"), selection)
|
|
63
|
+
session.validate_selection
|
|
64
|
+
selection
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
private
|
|
68
|
+
def path(relative)
|
|
69
|
+
File.join(@root, relative)
|
|
70
|
+
end
|
|
71
|
+
def reload
|
|
72
|
+
@metadata = A.object(path("session.json"))
|
|
73
|
+
@preset = A.object(path("resolved.json"))
|
|
74
|
+
@state = A.object(path("run.json"))
|
|
75
|
+
unless %w[view component].include?(@metadata["kind"]) && @state.dig("inputs", "design.md")
|
|
76
|
+
raise Error, "Session lacks task kind or frozen design assumptions; create a new session"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
def locked
|
|
80
|
+
raise Error, "Refusing symlink lock" if File.symlink?(path(".run.lock"))
|
|
81
|
+
File.open(path(".run.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
82
|
+
raise Error, "Session already has an active operation" unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
83
|
+
reload
|
|
84
|
+
Thread.handle_interrupt(Interrupt => :never) do
|
|
85
|
+
recover_publication
|
|
86
|
+
yield
|
|
87
|
+
end
|
|
88
|
+
rescue Interrupt => error
|
|
89
|
+
@state["status"] = "interrupted"
|
|
90
|
+
save_preserving_failure(error)
|
|
91
|
+
raise
|
|
92
|
+
ensure
|
|
93
|
+
lock.flock(File::LOCK_UN)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
def save
|
|
97
|
+
A.json(path("run.json"), @state)
|
|
98
|
+
end
|
|
99
|
+
# A diagnostic write must not replace the error that triggered it.
|
|
100
|
+
def save_preserving_failure(failure)
|
|
101
|
+
save
|
|
102
|
+
rescue StandardError => persistence_error
|
|
103
|
+
raise unless failure
|
|
104
|
+
warn "Could not persist design state: #{persistence_error.message}"
|
|
105
|
+
raise failure
|
|
106
|
+
end
|
|
107
|
+
def verify_inputs!
|
|
108
|
+
@state.fetch("inputs").each do |file, digest|
|
|
109
|
+
target = path(file)
|
|
110
|
+
unless File.file?(target) && !File.symlink?(target) && Digest::SHA256.file(target).hexdigest == digest
|
|
111
|
+
raise Error, "Shared input changed: #{file}; create a new session"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
def verify_published!
|
|
116
|
+
@state.fetch("workers").each do |id, worker|
|
|
117
|
+
next unless worker["published_sha256"]
|
|
118
|
+
unless A.digest(path("variants/#{id}")) == worker["published_sha256"]
|
|
119
|
+
raise Error, "Published variant #{id} changed outside the runner; create a new session"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
def workspace(id)
|
|
124
|
+
directory = path("work/#{id}/#{SecureRandom.hex(8)}")
|
|
125
|
+
FileUtils.mkdir_p(directory)
|
|
126
|
+
%w[brief.md context.md design.md fixtures.json].each do |name|
|
|
127
|
+
FileUtils.cp(path(name), File.join(directory, name)) if File.file?(path(name))
|
|
128
|
+
end
|
|
129
|
+
%w[source skills].each do |name|
|
|
130
|
+
FileUtils.cp_r(path(name), File.join(directory, name)) if File.directory?(path(name))
|
|
131
|
+
end
|
|
132
|
+
FileUtils.cp(path("source-analysis.md"), directory) if File.file?(path("source-analysis.md"))
|
|
133
|
+
directory
|
|
134
|
+
end
|
|
135
|
+
def invoke(directory, prompt, model:, execution_preset: nil, system: Molecules::DesignPrompts::SYSTEM)
|
|
136
|
+
same_project = begin
|
|
137
|
+
File.realpath(Dir.pwd) == File.realpath(@state.fetch("project_root"))
|
|
138
|
+
rescue SystemCallError
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
unless same_project
|
|
142
|
+
raise Error, "Run model operations from #{@state.fetch('project_root')} to retain the project's ace-llm policies"
|
|
143
|
+
end
|
|
144
|
+
File.write(File.join(directory, "system.md"), system)
|
|
145
|
+
File.write(File.join(directory, "prompt.md"), prompt)
|
|
146
|
+
@executor.validate_model(model, execution_preset: execution_preset)
|
|
147
|
+
result = Thread.handle_interrupt(Interrupt => :immediate) do
|
|
148
|
+
@executor.call(model: model, prompt: prompt, system: system,
|
|
149
|
+
workspace: directory, timeout: @preset.fetch("timeout"), execution_preset: execution_preset)
|
|
150
|
+
end
|
|
151
|
+
A.json(File.join(directory, "result.json"), result)
|
|
152
|
+
unless result.dig("execution", "status") == "succeeded"
|
|
153
|
+
raise Error, "Coding agent did not complete successfully; see #{directory}/result.json"
|
|
154
|
+
end
|
|
155
|
+
result
|
|
156
|
+
end
|
|
157
|
+
def check_copies(directory, names)
|
|
158
|
+
names += @state.fetch("inputs").keys.select { |name| name.start_with?("source/", "skills/") || name == "source-analysis.md" }
|
|
159
|
+
names.each do |name|
|
|
160
|
+
target = File.join(directory, name)
|
|
161
|
+
unless File.file?(target) && !File.symlink?(target) && File.binread(target) == File.binread(path(name))
|
|
162
|
+
raise Error, "Worker modified shared input #{name}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
def prepare
|
|
167
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
168
|
+
directory = workspace("preparation")
|
|
169
|
+
attempt = {"status" => "running", "workspace" => directory, "requested_model" => @preset.fetch("preparation_model")}
|
|
170
|
+
@state["preparation_attempts"] ||= []
|
|
171
|
+
@state["preparation_attempts"] << attempt
|
|
172
|
+
@state["preparation"] = attempt
|
|
173
|
+
@state["status"] = "running"
|
|
174
|
+
save
|
|
175
|
+
prompt = Molecules::DesignPrompts.preparation(@metadata, @preset, supplied: @state["supplied_fixtures"])
|
|
176
|
+
A.write(path("prompts/preparation.md"), prompt)
|
|
177
|
+
result = invoke(directory, prompt, model: @preset.fetch("preparation_model"))
|
|
178
|
+
attempt.merge!(evidence(result))
|
|
179
|
+
check_copies(directory, %w[brief.md context.md design.md] + (@state["supplied_fixtures"] ? ["fixtures.json"] : []))
|
|
180
|
+
fixture = File.join(directory, "fixtures.json")
|
|
181
|
+
directions_path = File.join(directory, "directions.json")
|
|
182
|
+
[fixture, directions_path].each { |f| raise Error, "Preparation output missing or symlink: #{f}" unless File.file?(f) && !File.symlink?(f) }
|
|
183
|
+
A.object(fixture)
|
|
184
|
+
directions = A.object(directions_path)
|
|
185
|
+
ids = @metadata.fetch("variants")
|
|
186
|
+
unless directions.keys.sort == ids.sort && directions.values.all? { |v| v.is_a?(String) && !v.strip.empty? }
|
|
187
|
+
raise Error, "directions.json must map every declared variant ID to a nonempty brief"
|
|
188
|
+
end
|
|
189
|
+
if @metadata["source"]
|
|
190
|
+
analysis = File.join(directory, "source-analysis.md")
|
|
191
|
+
raise Error, "Preparation must write source-analysis.md" unless File.file?(analysis) && !File.symlink?(analysis) && !File.read(analysis).strip.empty?
|
|
192
|
+
A.write(path("source-analysis.md"), File.binread(analysis))
|
|
193
|
+
@state["inputs"]["source-analysis.md"] = Digest::SHA256.file(analysis).hexdigest
|
|
194
|
+
end
|
|
195
|
+
verify_inputs!
|
|
196
|
+
A.write(path("fixtures.json"), File.binread(fixture)) unless @state["supplied_fixtures"]
|
|
197
|
+
A.json(path("directions.json"), directions)
|
|
198
|
+
%w[fixtures.json directions.json].each { |f| @state["inputs"][f] = Digest::SHA256.file(path(f)).hexdigest }
|
|
199
|
+
attempt["status"] = "succeeded"
|
|
200
|
+
@preset.fetch("variants").each do |slot|
|
|
201
|
+
File.write(path("prompts/#{slot.fetch('id')}.md"), Molecules::DesignPrompts.variant(@metadata, slot, direction: directions.fetch(slot.fetch("id"))))
|
|
202
|
+
end
|
|
203
|
+
rescue StandardError => e
|
|
204
|
+
failure = e
|
|
205
|
+
raise unless attempt
|
|
206
|
+
attempt.merge!("status" => "failed", "error" => e.message)
|
|
207
|
+
rescue Interrupt => error
|
|
208
|
+
failure = error
|
|
209
|
+
attempt["status"] = "interrupted" if attempt
|
|
210
|
+
raise
|
|
211
|
+
ensure
|
|
212
|
+
if attempt && started
|
|
213
|
+
attempt["duration"] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
|
|
214
|
+
save_preserving_failure(failure)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
def execute_slots(slots)
|
|
218
|
+
failure = nil
|
|
219
|
+
threads = []
|
|
220
|
+
begin
|
|
221
|
+
@state["status"] = "running"
|
|
222
|
+
save
|
|
223
|
+
queue = Queue.new
|
|
224
|
+
slots.each { |slot| queue << slot }
|
|
225
|
+
[@preset.fetch("concurrency"), slots.size].min.times do
|
|
226
|
+
threads << Thread.new do
|
|
227
|
+
Thread.current.report_on_exception = false
|
|
228
|
+
loop do
|
|
229
|
+
slot = begin
|
|
230
|
+
queue.pop(true)
|
|
231
|
+
rescue ThreadError
|
|
232
|
+
break
|
|
233
|
+
end
|
|
234
|
+
execute_slot(slot)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
Thread.handle_interrupt(Interrupt => :immediate) { threads.each(&:value) }
|
|
239
|
+
rescue Interrupt, StandardError => error
|
|
240
|
+
failure = error
|
|
241
|
+
ensure
|
|
242
|
+
# Interrupt the provider capture boundary, which owns descendant cleanup.
|
|
243
|
+
threads.each { |t| t.raise(Interrupt) if t.alive? }
|
|
244
|
+
threads.each do |thread|
|
|
245
|
+
begin
|
|
246
|
+
thread.join
|
|
247
|
+
rescue Interrupt, StandardError => error
|
|
248
|
+
# Join every sibling even if recording a failure also failed.
|
|
249
|
+
failure ||= error
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
raise failure if failure
|
|
254
|
+
end
|
|
255
|
+
def execute_slot(slot, feedback: nil)
|
|
256
|
+
# Cancellation is delivered at the provider boundary. File publication
|
|
257
|
+
# and its journal must finish atomically with respect to Ruby interrupts.
|
|
258
|
+
Thread.handle_interrupt(Interrupt => :never) { execute_slot_work(slot, feedback: feedback) }
|
|
259
|
+
end
|
|
260
|
+
def execute_slot_work(slot, feedback: nil)
|
|
261
|
+
id = slot.fetch("id")
|
|
262
|
+
worker = @state.fetch("workers").fetch(id)
|
|
263
|
+
# A failed/interrupted revision is resumed with the same feedback, not a fresh generation.
|
|
264
|
+
feedback ||= worker["pending_feedback"]
|
|
265
|
+
directory = workspace(id)
|
|
266
|
+
output = File.join(directory, "variants", id)
|
|
267
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
268
|
+
FileUtils.cp_r(path("variants/#{id}"), output) if feedback
|
|
269
|
+
FileUtils.mkdir_p(output)
|
|
270
|
+
directions = A.object(path("directions.json"))
|
|
271
|
+
prompt = Molecules::DesignPrompts.variant(@metadata, slot, direction: directions.fetch(id), feedback: feedback)
|
|
272
|
+
attempt = {"status" => "running", "workspace" => directory, "requested_model" => slot.fetch("model"),
|
|
273
|
+
"feedback" => feedback, "started_at" => Time.now.utc.iso8601}
|
|
274
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
275
|
+
@mutex.synchronize do
|
|
276
|
+
worker["status"] = "running"
|
|
277
|
+
worker["pending_feedback"] = feedback
|
|
278
|
+
worker.fetch("attempts") << attempt
|
|
279
|
+
save
|
|
280
|
+
end
|
|
281
|
+
warn "#{id}: generating with #{slot.fetch('model')}"
|
|
282
|
+
result = invoke(directory, prompt, model: slot.fetch("model"), execution_preset: slot["execution_preset"],
|
|
283
|
+
system: slot.fetch("process", "ace") == "ace" ? Molecules::DesignPrompts::SYSTEM : Molecules::DesignPrompts::EXTERNAL_SYSTEM)
|
|
284
|
+
@mutex.synchronize { attempt.merge!(evidence(result)); save }
|
|
285
|
+
check_copies(directory, %w[brief.md context.md design.md fixtures.json])
|
|
286
|
+
digest = A.validate_variant(output, types: slot.fetch("artifact_types", ["html"]), shared_reference: slot.fetch("process", "ace") == "ace")
|
|
287
|
+
@mutex.synchronize do
|
|
288
|
+
verify_inputs!
|
|
289
|
+
publish(id, output, digest)
|
|
290
|
+
worker.merge!("status" => "succeeded", "published_sha256" => digest)
|
|
291
|
+
worker.delete("pending_feedback")
|
|
292
|
+
attempt.merge!(evidence(result)).merge!("status" => "succeeded")
|
|
293
|
+
if feedback
|
|
294
|
+
File.open(path("feedback.md"), "a") do |file|
|
|
295
|
+
file.puts "\n## #{Time.now.utc.iso8601} — #{id}\n\n#{feedback}\n\nPublished: #{digest}\nAttempt: #{directory}\n"
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
save
|
|
299
|
+
@state.delete("publication")
|
|
300
|
+
save
|
|
301
|
+
Molecules::Session.new(@root).write_gallery
|
|
302
|
+
end
|
|
303
|
+
warn "#{id}: ready"
|
|
304
|
+
rescue StandardError => e
|
|
305
|
+
failure = e
|
|
306
|
+
warn "#{id}: failed — #{e.message}"
|
|
307
|
+
@mutex.synchronize do
|
|
308
|
+
recover_publication
|
|
309
|
+
worker["status"] = "failed"
|
|
310
|
+
attempt&.merge!("status" => "failed", "error" => e.message)
|
|
311
|
+
save_preserving_failure(e)
|
|
312
|
+
end
|
|
313
|
+
rescue Interrupt => error
|
|
314
|
+
failure = error
|
|
315
|
+
@mutex.synchronize do
|
|
316
|
+
worker["status"] = "interrupted"
|
|
317
|
+
attempt&.merge!("status" => "interrupted")
|
|
318
|
+
save_preserving_failure(error)
|
|
319
|
+
end
|
|
320
|
+
raise
|
|
321
|
+
ensure
|
|
322
|
+
if attempt
|
|
323
|
+
@mutex.synchronize do
|
|
324
|
+
attempt["duration"] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
|
|
325
|
+
save_preserving_failure(failure)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
def publish(id, output, digest)
|
|
330
|
+
target = path("variants/#{id}")
|
|
331
|
+
backup = path("history/#{id}/#{SecureRandom.hex(8)}")
|
|
332
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
333
|
+
FileUtils.mkdir_p(File.dirname(backup))
|
|
334
|
+
previous = @state.fetch("workers").fetch(id)["published_sha256"]
|
|
335
|
+
selection = path("selection.json")
|
|
336
|
+
selection_backup = if File.exist?(selection) && A.object(selection)["variant"] == id
|
|
337
|
+
path("history/selections/#{SecureRandom.hex(8)}.json")
|
|
338
|
+
end
|
|
339
|
+
@state["publication"] = {"id" => id, "backup" => backup, "previous" => previous,
|
|
340
|
+
"digest" => digest, "selection_backup" => selection_backup}
|
|
341
|
+
save
|
|
342
|
+
File.rename(target, backup) if File.exist?(target)
|
|
343
|
+
File.rename(output, target)
|
|
344
|
+
if selection_backup
|
|
345
|
+
FileUtils.mkdir_p(File.dirname(selection_backup))
|
|
346
|
+
File.rename(selection, selection_backup)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
def recover_publication
|
|
350
|
+
publication = @state["publication"]
|
|
351
|
+
return unless publication
|
|
352
|
+
id = publication.fetch("id")
|
|
353
|
+
target = path("variants/#{id}")
|
|
354
|
+
worker = @state.fetch("workers").fetch(id)
|
|
355
|
+
# A saved success is committed even if the process stopped before clearing the journal.
|
|
356
|
+
if worker["status"] == "succeeded" && worker["published_sha256"] == publication["digest"] &&
|
|
357
|
+
File.directory?(target) && A.digest(target) == publication["digest"]
|
|
358
|
+
@state.delete("publication")
|
|
359
|
+
save
|
|
360
|
+
return
|
|
361
|
+
end
|
|
362
|
+
if File.directory?(publication.fetch("backup"))
|
|
363
|
+
FileUtils.rm_rf(target)
|
|
364
|
+
File.rename(publication.fetch("backup"), target)
|
|
365
|
+
elsif !publication["previous"]
|
|
366
|
+
FileUtils.rm_rf(target)
|
|
367
|
+
end
|
|
368
|
+
if publication["selection_backup"] && File.file?(publication["selection_backup"])
|
|
369
|
+
File.rename(publication["selection_backup"], path("selection.json"))
|
|
370
|
+
end
|
|
371
|
+
worker["status"] = "interrupted"
|
|
372
|
+
worker["published_sha256"] = publication["previous"]
|
|
373
|
+
@state.delete("publication")
|
|
374
|
+
save
|
|
375
|
+
end
|
|
376
|
+
def evidence(result)
|
|
377
|
+
result.slice("execution", "model", "provider", "usage", "metadata")
|
|
378
|
+
end
|
|
379
|
+
def finish
|
|
380
|
+
@state["status"] = if @state["preparation"]["status"] != "succeeded"
|
|
381
|
+
"failed"
|
|
382
|
+
elsif @state.fetch("workers").values.all? { |w| w["status"] == "succeeded" }
|
|
383
|
+
"succeeded"
|
|
384
|
+
else
|
|
385
|
+
"incomplete"
|
|
386
|
+
end
|
|
387
|
+
save
|
|
388
|
+
Molecules::Session.new(@root).write_gallery if File.file?(path("directions.json"))
|
|
389
|
+
status
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|