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,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "uri"
|
|
3
|
+
module Ace
|
|
4
|
+
module Design
|
|
5
|
+
module Molecules
|
|
6
|
+
# Resolves provenance without fetching during dry-run; captures once before preparation.
|
|
7
|
+
class DesignSource
|
|
8
|
+
attr_reader :info
|
|
9
|
+
def initialize(reference, config: Design.config)
|
|
10
|
+
@config = config
|
|
11
|
+
@info = if reference.nil?
|
|
12
|
+
nil
|
|
13
|
+
elsif reference.start_with?("session:")
|
|
14
|
+
match = /\Asession:([0-9a-z]+)\/([a-z][a-z0-9_-]{0,31})\z/.match(reference)
|
|
15
|
+
raise Error, "Expected session:TOKEN/VARIANT" unless match
|
|
16
|
+
root = File.expand_path(File.join(config.fetch("run_root"), match[1]))
|
|
17
|
+
session = Session.new(root)
|
|
18
|
+
raise Error, "Source variant is not available" unless session.variants.include?(match[2])
|
|
19
|
+
%w[fixtures.json design.md brief.md].each do |name|
|
|
20
|
+
unless File.file?(File.join(root, name)) && File.readable?(File.join(root, name))
|
|
21
|
+
raise Error, "Source session #{match[1]} is missing readable #{name}; create a new session with complete shared inputs"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
Artifacts.files(File.join(root, "variants", match[2]))
|
|
25
|
+
{"type" => "session", "reference" => reference, "root" => root, "variant" => match[2],
|
|
26
|
+
"surface" => session.metadata.fetch("surface"), "kind" => session.metadata.fetch("kind")}
|
|
27
|
+
elsif reference.match?(/\Ahttps?:\/\//)
|
|
28
|
+
uri = URI.parse(reference)
|
|
29
|
+
raise Error, "Source URL requires a host and no credentials" if !uri.host || uri.userinfo
|
|
30
|
+
{"type" => "url", "reference" => reference}
|
|
31
|
+
else
|
|
32
|
+
file = File.realpath(reference)
|
|
33
|
+
raise Error, "Source must be a PNG, JPEG, WebP or GIF file" unless File.file?(file) && file.match?(/\.(png|jpe?g|webp|gif)\z/i)
|
|
34
|
+
{"type" => "image", "reference" => file}
|
|
35
|
+
end
|
|
36
|
+
rescue SystemCallError, URI::InvalidURIError => e
|
|
37
|
+
raise Error, "Invalid design source: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
def self.scope(surface:, kind:, profile: nil, source: nil)
|
|
40
|
+
expected = profile ? profile.split("/").first(2) : [source&.dig("surface"), source&.dig("kind")]
|
|
41
|
+
expected[1] = nil unless %w[view component].include?(expected[1])
|
|
42
|
+
[[surface, expected[0], "surface"], [kind, expected[1], "kind"]].each do |explicit, inherited, field|
|
|
43
|
+
raise Error, "Conflicting #{field}: #{explicit} versus #{inherited}" if explicit && inherited && explicit != inherited
|
|
44
|
+
end
|
|
45
|
+
result = [surface || expected[0], kind || expected[1] || "view"]
|
|
46
|
+
Atoms::Contracts.surface(result[0])
|
|
47
|
+
Atoms::Contracts.kind(result[1])
|
|
48
|
+
if source && source["surface"] && result != source.values_at("surface", "kind")
|
|
49
|
+
raise Error, "Source session scope conflicts with the selected profile/task"
|
|
50
|
+
end
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
def capture_url(url, target)
|
|
54
|
+
runtime = Runtime.new(@config)
|
|
55
|
+
result = runtime.capture(runtime.node, runtime.script("source.mjs"), runtime.directory, url, target, timeout: 90)
|
|
56
|
+
raise Error, "Source capture failed: #{result['stderr']}" unless result["exit_code"] == 0
|
|
57
|
+
end
|
|
58
|
+
def requirements
|
|
59
|
+
remote = info && (info["type"] == "url" || (info["type"] == "session" &&
|
|
60
|
+
Artifacts.descriptor(File.join(info.fetch("root"), "variants", info.fetch("variant")))["type"] == "url"))
|
|
61
|
+
return [] unless remote
|
|
62
|
+
[{"name" => "Source capture: configured Node, Playwright and Chromium", "status" => "agent_check",
|
|
63
|
+
"check" => "ace-design doctor", "node" => @config.fetch("node"), "runtime_dir" => @config.fetch("runtime_dir")}]
|
|
64
|
+
end
|
|
65
|
+
def snapshot(root)
|
|
66
|
+
return unless info
|
|
67
|
+
target = File.join(root, "source")
|
|
68
|
+
FileUtils.mkdir_p(target)
|
|
69
|
+
case info.fetch("type")
|
|
70
|
+
when "image"
|
|
71
|
+
FileUtils.cp(info.fetch("reference"), File.join(target, "mockup#{File.extname(info.fetch('reference')).downcase}"))
|
|
72
|
+
when "url"
|
|
73
|
+
capture_url(info.fetch("reference"), target)
|
|
74
|
+
when "session"
|
|
75
|
+
source = File.join(info.fetch("root"), "variants", info.fetch("variant"))
|
|
76
|
+
Artifacts.files(source)
|
|
77
|
+
FileUtils.cp_r(source, File.join(target, "variant"))
|
|
78
|
+
artifact = Artifacts.descriptor(source)
|
|
79
|
+
capture_url(artifact.fetch("entry"), target) if artifact["type"] == "url"
|
|
80
|
+
%w[fixtures.json design.md brief.md].each do |name|
|
|
81
|
+
FileUtils.cp(File.join(info.fetch("root"), name), File.join(target, name))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
Artifacts.json(File.join(target, "source.json"), info)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Ace
|
|
3
|
+
module Design
|
|
4
|
+
module Molecules
|
|
5
|
+
class Preset
|
|
6
|
+
ID = /\A[a-z][a-z0-9_-]{0,31}\z/
|
|
7
|
+
def self.integer(value, label)
|
|
8
|
+
unless value.is_a?(Integer) || (value.is_a?(String) && /\A[+-]?\d+\z/.match?(value))
|
|
9
|
+
raise Error, "#{label} must be an integer"
|
|
10
|
+
end
|
|
11
|
+
Integer(value)
|
|
12
|
+
end
|
|
13
|
+
def self.definition(name)
|
|
14
|
+
if File.file?(name)
|
|
15
|
+
data = YAML.safe_load_file(name)
|
|
16
|
+
raise Error, "Preset file must be an object" unless data.is_a?(Hash)
|
|
17
|
+
data
|
|
18
|
+
else
|
|
19
|
+
raise Error, "Invalid preset name" unless ID.match?(name)
|
|
20
|
+
data = Ace::Support::Config.create(gem_path: Design.gem_root)
|
|
21
|
+
.resolve_namespace("design", filename: "presets/#{name}").to_h
|
|
22
|
+
raise Error, "Unknown preset: #{name}" if data.empty?
|
|
23
|
+
data
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
def self.compatibility!(data, intent, surface, kind)
|
|
27
|
+
{"intents" => intent, "surfaces" => surface, "kinds" => kind}.each do |key, value|
|
|
28
|
+
next unless data[key]
|
|
29
|
+
allowed = {"intents" => %w[explore recreate reimagine], "surfaces" => %w[site app], "kinds" => %w[view component]}.fetch(key)
|
|
30
|
+
unless data[key].is_a?(Array) && !data[key].empty? && (data[key] - allowed).empty?
|
|
31
|
+
raise Error, "Preset #{key} must be a nonempty list drawn from #{allowed.join(', ')}"
|
|
32
|
+
end
|
|
33
|
+
raise Error, "Preset does not support #{key}: #{value}" unless data.fetch(key).include?(value)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
def self.list
|
|
37
|
+
paths = Ace::Support::Config::Molecules::ConfigFinder.new(gem_path: Design.gem_root,
|
|
38
|
+
file_patterns: ["design/presets/*.yml", "design/presets/*.yaml"]).find_all
|
|
39
|
+
paths.map { |entry| File.basename(entry.path).sub(/\.ya?ml\z/, "") }
|
|
40
|
+
.uniq.sort.map { |name| definition(name).merge("name" => name) }
|
|
41
|
+
end
|
|
42
|
+
def self.load(name:, surface:, config: Design.config, model: nil, prompts: [], prompt_files: [], kind: "view", intent: "explore")
|
|
43
|
+
raise Error, "Invalid preset name" unless name.is_a?(String) && (ID.match?(name) || File.file?(name))
|
|
44
|
+
Atoms::Contracts.surface(surface)
|
|
45
|
+
Atoms::Contracts.kind(kind)
|
|
46
|
+
data = definition(name)
|
|
47
|
+
raise Error, "Unknown or empty design preset: #{name}" if data.empty?
|
|
48
|
+
raise Error, "Unsupported intent: #{intent}" unless %w[explore recreate reimagine].include?(intent)
|
|
49
|
+
compatibility!(data, intent, surface, kind)
|
|
50
|
+
slots = data["variants"] || Array.new(intent == "recreate" ? 1 : 3) { |i| {"id" => (97 + i).chr} }
|
|
51
|
+
unless slots.is_a?(Array) && !slots.empty? && slots.all? { |slot| slot.is_a?(Hash) }
|
|
52
|
+
raise Error, "Preset variants must be a nonempty list of objects"
|
|
53
|
+
end
|
|
54
|
+
slots = slots.map do |slot|
|
|
55
|
+
base = slot["preset"] ? definition(slot["preset"]) : data
|
|
56
|
+
raise Error, "A variant must reference a single-process preset, not a comparison" if slot["preset"] && base.key?("variants")
|
|
57
|
+
compatibility!(base, intent, surface, kind)
|
|
58
|
+
JSON.parse(JSON.generate(base.reject { |key, _| %w[variants concurrency].include?(key) }.merge(slot)))
|
|
59
|
+
end
|
|
60
|
+
unless slots.is_a?(Array) && !slots.empty? && slots.all? { |s| s.is_a?(Hash) && ID.match?(s["id"].to_s) }
|
|
61
|
+
raise Error, "Preset variants must be a nonempty list with safe lowercase IDs"
|
|
62
|
+
end
|
|
63
|
+
ids = slots.map { |s| s["id"] }
|
|
64
|
+
raise Error, "Variant IDs must be unique" unless ids.uniq == ids
|
|
65
|
+
extras = ids.to_h { |id| [id, []] }
|
|
66
|
+
[[prompts, false], [prompt_files, true]].each do |entries, file|
|
|
67
|
+
entries.each do |entry|
|
|
68
|
+
id, value = entry.split("=", 2)
|
|
69
|
+
raise Error, "Expected declared variant ID=VALUE: #{entry}" unless extras.key?(id) && value && !value.empty?
|
|
70
|
+
begin
|
|
71
|
+
extras[id] << (file ? File.read(value) : value)
|
|
72
|
+
rescue SystemCallError => e
|
|
73
|
+
raise Error, "Variant prompt file #{id}=#{value}: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
slots = slots.map do |slot|
|
|
78
|
+
focus = slot["focus"] || Array(data["directions"])[ids.index(slot["id"])]
|
|
79
|
+
focus = (kind == "component" && focus["component"]) || focus[surface] if focus.is_a?(Hash)
|
|
80
|
+
resolved = slot.merge("focus" => focus.to_s,
|
|
81
|
+
"model" => model || slot["model"] || data["model"] || config.fetch("model"),
|
|
82
|
+
"role" => slot.fetch("role", "Design a distinct, useful #{surface} mockup."),
|
|
83
|
+
"prompt" => ([slot["prompt"]] + extras.fetch(slot["id"])).compact.join("\n\n"))
|
|
84
|
+
%w[model role prompt focus].each do |key|
|
|
85
|
+
raise Error, "Variant #{slot['id']} #{key} must be text" unless resolved[key].is_a?(String)
|
|
86
|
+
end
|
|
87
|
+
Atoms::Contracts.text(resolved["model"], "variant model")
|
|
88
|
+
if resolved.key?("execution_preset")
|
|
89
|
+
Atoms::Contracts.text(resolved["execution_preset"], "execution preset")
|
|
90
|
+
raise Error, "Use model@preset or execution_preset, not both" if resolved["model"].include?("@")
|
|
91
|
+
end
|
|
92
|
+
resolved.delete("directions")
|
|
93
|
+
process = resolved.fetch("process", "ace")
|
|
94
|
+
raise Error, "Unknown preset process: #{process}" unless %w[ace external].include?(process)
|
|
95
|
+
if process == "external"
|
|
96
|
+
skills = resolved["skills"]
|
|
97
|
+
unless skills.is_a?(Array) && !skills.empty? && skills.all? { |skill| skill.is_a?(Hash) && skill["source"].is_a?(String) && skill["entry"].is_a?(String) && /\A[0-9a-f]{40}\z/.match?(skill["revision"].to_s) }
|
|
98
|
+
raise Error, "External preset requires pinned skill sources, entries and full revisions"
|
|
99
|
+
end
|
|
100
|
+
raise Error, "External preset needs exactly one lead skill" unless skills.count { |skill| skill.fetch("role", "lead") == "lead" } == 1
|
|
101
|
+
end
|
|
102
|
+
types = resolved.fetch("artifact_types", ["html"])
|
|
103
|
+
raise Error, "Invalid artifact_types" unless types.is_a?(Array) && !types.empty? && (types - %w[html image url]).empty?
|
|
104
|
+
resolved
|
|
105
|
+
end
|
|
106
|
+
concurrency = integer(data.fetch("concurrency", 3), "concurrency")
|
|
107
|
+
timeout = integer(data.fetch("timeout", config.fetch("agent_timeout")), "agent timeout")
|
|
108
|
+
raise Error, "concurrency must be positive" unless concurrency.positive?
|
|
109
|
+
raise Error, "agent timeout must be 1..3600 seconds" unless (1..3600).cover?(timeout)
|
|
110
|
+
{"name" => name, "variants" => slots, "concurrency" => [concurrency, slots.size].min,
|
|
111
|
+
"timeout" => timeout, "preparation_model" => model || data["preparation_model"] || data["model"] || config.fetch("model"),
|
|
112
|
+
"input" => Array(config["input"]) + Array(data["input"]), "intent" => intent,
|
|
113
|
+
"model_override" => model, "description" => data["description"]}
|
|
114
|
+
rescue SystemCallError, ArgumentError, TypeError => e
|
|
115
|
+
raise Error, "Invalid design preset: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "open3"
|
|
3
|
+
require "ace/core/atoms/process_terminator"
|
|
4
|
+
module Ace
|
|
5
|
+
module Design
|
|
6
|
+
module Molecules
|
|
7
|
+
class Runtime
|
|
8
|
+
attr_reader :node, :directory
|
|
9
|
+
def initialize(config = Design.config)
|
|
10
|
+
@node = config.fetch("node")
|
|
11
|
+
@directory = File.expand_path(config.fetch("runtime_dir"))
|
|
12
|
+
end
|
|
13
|
+
def script(name)
|
|
14
|
+
File.join(Design.gem_root, "browser", name)
|
|
15
|
+
end
|
|
16
|
+
def capture(*argv, timeout:)
|
|
17
|
+
output = [String.new, String.new]
|
|
18
|
+
status = nil
|
|
19
|
+
Open3.popen3(*argv, pgroup: true) do |stdin, stdout, stderr, waiter|
|
|
20
|
+
stdin.close
|
|
21
|
+
readers = [stdout, stderr].each_with_index.map do |stream, i|
|
|
22
|
+
Thread.new do
|
|
23
|
+
loop do
|
|
24
|
+
chunk = stream.readpartial(16_384)
|
|
25
|
+
remaining = 262_144 - output[i].bytesize
|
|
26
|
+
output[i] << chunk.byteslice(0, remaining) if remaining.positive?
|
|
27
|
+
end
|
|
28
|
+
rescue EOFError, IOError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
begin
|
|
33
|
+
unless waiter.join(timeout)
|
|
34
|
+
raise Error, "Command #{argv.first.inspect} timed out after #{timeout}s"
|
|
35
|
+
end
|
|
36
|
+
status = waiter.value
|
|
37
|
+
ensure
|
|
38
|
+
# A completed command can leave descendants holding its pipes. Their
|
|
39
|
+
# lifetime belongs to this capture, not to the caller's next command.
|
|
40
|
+
Thread.handle_interrupt(Interrupt => :never) do
|
|
41
|
+
Ace::Core::Atoms::ProcessTerminator.terminate(-waiter.pid)
|
|
42
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
|
|
43
|
+
readers.each do |reader|
|
|
44
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
45
|
+
reader.join([remaining, 0].max)
|
|
46
|
+
end
|
|
47
|
+
[stdout, stderr].each { |io| io.close unless io.closed? }
|
|
48
|
+
readers.each { |reader| reader.kill if reader.alive? }
|
|
49
|
+
readers.each { |reader| reader.join(0.1) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
{"stdout" => output[0], "stderr" => output[1], "exit_code" => status.exitstatus}
|
|
54
|
+
rescue Errno::ENOENT
|
|
55
|
+
raise Error, "Command executable unavailable: #{argv.first}"
|
|
56
|
+
end
|
|
57
|
+
def doctor
|
|
58
|
+
result = capture(node, script("doctor.mjs"), directory, timeout: 15)
|
|
59
|
+
data = JSON.parse(result["stdout"])
|
|
60
|
+
data["runtime_dir"] = directory
|
|
61
|
+
data["manifest_dir"] = File.join(Design.gem_root, "browser")
|
|
62
|
+
data
|
|
63
|
+
rescue Error, JSON::ParserError => e
|
|
64
|
+
{"ready" => false, "error" => e.message, "runtime_dir" => directory,
|
|
65
|
+
"manifest_dir" => File.join(Design.gem_root, "browser")}
|
|
66
|
+
end
|
|
67
|
+
def preview(session, port: 0)
|
|
68
|
+
Kernel.exec(node, script("preview.mjs"), session.root, port.to_s)
|
|
69
|
+
rescue Errno::ENOENT
|
|
70
|
+
raise Error, "Preview requires Node 22+; configured executable: #{node}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "digest"
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "pathname"
|
|
5
|
+
module Ace
|
|
6
|
+
module Design
|
|
7
|
+
module Molecules
|
|
8
|
+
class Session
|
|
9
|
+
attr_reader :root, :metadata, :fixtures
|
|
10
|
+
def initialize(path)
|
|
11
|
+
@root = File.realpath(path)
|
|
12
|
+
raise Error, "session must be a directory" unless File.directory?(root)
|
|
13
|
+
@metadata = read_json("session.json")
|
|
14
|
+
Atoms::Contracts.surface(metadata["surface"])
|
|
15
|
+
Atoms::Contracts.text(metadata["title"], "session title")
|
|
16
|
+
Atoms::Contracts.text(metadata["language"], "session language")
|
|
17
|
+
ids = metadata["variants"]
|
|
18
|
+
unless ids.is_a?(Array) && !ids.empty? && ids.uniq == ids &&
|
|
19
|
+
ids.all? { |id| id.is_a?(String) && Preset::ID.match?(id) }
|
|
20
|
+
raise Error, "session variants must be a nonempty list of unique safe IDs"
|
|
21
|
+
end
|
|
22
|
+
@fixtures = read_json("fixtures.json")
|
|
23
|
+
safe_file("brief.md")
|
|
24
|
+
rescue Errno::ENOENT, Errno::ENOTDIR => e
|
|
25
|
+
raise Error, "Session missing or incomplete: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
def safe_file(relative)
|
|
28
|
+
path = File.realpath(File.join(root, relative))
|
|
29
|
+
unless path.start_with?(root + File::SEPARATOR) && File.file?(path)
|
|
30
|
+
raise Error, "Session file escapes session root: #{relative}"
|
|
31
|
+
end
|
|
32
|
+
path
|
|
33
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ELOOP
|
|
34
|
+
raise Error, "Session file missing or broken symlink: #{relative}"
|
|
35
|
+
end
|
|
36
|
+
def read_json(relative)
|
|
37
|
+
Atoms::Contracts.object(JSON.parse(File.read(safe_file(relative))), relative)
|
|
38
|
+
rescue JSON::ParserError => e
|
|
39
|
+
raise Error, "Invalid #{relative}: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
def variants
|
|
42
|
+
metadata.fetch("variants").select do |id|
|
|
43
|
+
directory = File.join(root, "variants", id)
|
|
44
|
+
next false unless File.directory?(directory)
|
|
45
|
+
next false unless File.file?(File.join(directory, "direction.md"))
|
|
46
|
+
Artifacts.files(directory)
|
|
47
|
+
artifact = Artifacts.descriptor(directory)
|
|
48
|
+
safe_file("variants/#{id}/#{artifact.fetch('entry')}") unless artifact["type"] == "url"
|
|
49
|
+
safe_file("variants/#{id}/direction.md")
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
def validate(allow_empty: false)
|
|
54
|
+
available = variants
|
|
55
|
+
raise Error, "No ready variants: add variants/#{metadata.fetch("variants").first}/index.html and direction.md" if available.empty? && !allow_empty
|
|
56
|
+
selection = File.join(root, "selection.json")
|
|
57
|
+
validate_selection if File.exist?(selection) || File.symlink?(selection)
|
|
58
|
+
{"surface" => metadata["surface"], "variants" => available,
|
|
59
|
+
"missing_variants" => metadata.fetch("variants") - available, "selected" => File.exist?(selection)}
|
|
60
|
+
end
|
|
61
|
+
def validate_selection
|
|
62
|
+
chosen = read_json("selection.json")
|
|
63
|
+
unless chosen["surface"] == metadata["surface"] && variants.include?(chosen["variant"])
|
|
64
|
+
raise Error, "Selection must name an available variant and the session surface"
|
|
65
|
+
end
|
|
66
|
+
expected = Digest::SHA256.file(safe_file("fixtures.json")).hexdigest
|
|
67
|
+
raise Error, "Selection is stale: fixtures changed; confirm the direction again" unless chosen["fixtures_sha256"] == expected
|
|
68
|
+
if chosen["variant_sha256"] != Artifacts.digest(File.join(root, "variants", chosen["variant"]))
|
|
69
|
+
raise Error, "Selection is stale: variant changed; confirm the direction again"
|
|
70
|
+
end
|
|
71
|
+
decisions = chosen["decisions"]
|
|
72
|
+
unless decisions.is_a?(Array) && !decisions.empty? && decisions.all? { |s| s.is_a?(String) && !s.strip.empty? }
|
|
73
|
+
raise Error, "Selection decisions must be a nonempty list of text"
|
|
74
|
+
end
|
|
75
|
+
chosen
|
|
76
|
+
end
|
|
77
|
+
def write_gallery
|
|
78
|
+
result = validate(allow_empty: true)
|
|
79
|
+
path = File.join(root, "gallery.html")
|
|
80
|
+
raise Error, "Refusing symlink gallery.html" if File.symlink?(path)
|
|
81
|
+
cards = result["variants"].map do |id|
|
|
82
|
+
direction = CGI.escapeHTML(File.read(safe_file("variants/#{id}/direction.md")))
|
|
83
|
+
artifact = Artifacts.descriptor(File.join(root, "variants", id))
|
|
84
|
+
entry = artifact.fetch("entry")
|
|
85
|
+
url = CGI.escapeHTML(artifact["type"] == "url" ? entry : "variants/#{id}/#{entry}")
|
|
86
|
+
visual = case artifact["type"]
|
|
87
|
+
when "html" then %(<div class="frame"><iframe title="Direction #{id.upcase}" src="#{url}" sandbox="allow-scripts"></iframe></div>)
|
|
88
|
+
when "image" then %(<img src="#{url}" alt="Direction #{id.upcase}" style="max-width:100%">)
|
|
89
|
+
else %(<p>External result — open the provider preview. Remote content may change independently.</p>)
|
|
90
|
+
end
|
|
91
|
+
%(<section><h2>#{id.upcase} <a href="#{url}" target="_blank" rel="noopener noreferrer">Open #{id.upcase}</a></h2><p>#{artifact.fetch('type')}</p>#{visual}<details><summary>Direction notes</summary><p class="direction">#{direction}</p></details></section>)
|
|
92
|
+
end.join("\n")
|
|
93
|
+
title = CGI.escapeHTML(metadata["title"])
|
|
94
|
+
run = File.file?(File.join(root, "run.json")) ? read_json("run.json") : nil
|
|
95
|
+
missing = result["missing_variants"].empty? ? "" : "Still preparing: #{result['missing_variants'].join(', ')}"
|
|
96
|
+
if run && !result["missing_variants"].empty?
|
|
97
|
+
labels = result["missing_variants"].map { |id| "#{id} (#{run.dig('workers', id, 'status') || 'pending'})" }
|
|
98
|
+
missing = CGI.escapeHTML("Not ready: #{labels.join(', ')}")
|
|
99
|
+
end
|
|
100
|
+
refresh = run && run["status"] == "running" ? '<meta http-equiv="refresh" content="3">' : ""
|
|
101
|
+
html = <<~HTML
|
|
102
|
+
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
103
|
+
<title>#{title} — directions</title>#{refresh}<style>
|
|
104
|
+
*{box-sizing:border-box}body{margin:0;padding:24px;background:#f5f5f5;color:#171717;font:16px system-ui}
|
|
105
|
+
main{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:24px}
|
|
106
|
+
section{min-width:0}h2 a{font-size:16px;font-weight:normal;margin-left:12px}.frame{overflow:hidden;border:1px solid #ccc;background:white}iframe{display:block;width:1200px;height:900px;border:0;transform-origin:top left}details{margin-top:12px}
|
|
107
|
+
.direction{white-space:pre-wrap}a{color:#174dad}header{max-width:85ch;margin-bottom:24px}
|
|
108
|
+
</style><header><h1>#{title}</h1><p>Compare directions. Give feedback in chat or with ace-design revise using a variant ID. These are mockups.</p><p>#{missing}</p><label>Preview width <select id="width"><option value="1200">Desktop</option><option value="768">Tablet</option><option value="390">Mobile</option></select></label></header><main>#{cards}</main>
|
|
109
|
+
<script>
|
|
110
|
+
const select=document.getElementById('width');
|
|
111
|
+
function size(frame){const width=Number(select.value),scale=frame.clientWidth/width,iframe=frame.querySelector('iframe');iframe.style.width=width+'px';iframe.style.transform='scale('+scale+')';frame.style.height=(900*scale)+'px';}
|
|
112
|
+
const frames=[...document.querySelectorAll('.frame')];
|
|
113
|
+
const observer=new ResizeObserver(entries=>entries.forEach(entry=>size(entry.target)));
|
|
114
|
+
frames.forEach(frame=>observer.observe(frame));select.addEventListener('change',()=>frames.forEach(size));
|
|
115
|
+
</script></html>
|
|
116
|
+
HTML
|
|
117
|
+
File.write(path, html)
|
|
118
|
+
path
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
module Ace
|
|
4
|
+
module Design
|
|
5
|
+
module Molecules
|
|
6
|
+
class SkillSources
|
|
7
|
+
def initialize(config = Design.config)
|
|
8
|
+
@config = config
|
|
9
|
+
end
|
|
10
|
+
def requirements(preset)
|
|
11
|
+
preset.fetch("variants").flat_map do |slot|
|
|
12
|
+
Array(slot["requirements"]).map do |requirement|
|
|
13
|
+
raise Error, "Requirements must be objects with a name" unless requirement.is_a?(Hash) && requirement["name"]
|
|
14
|
+
executable = requirement["executable"]
|
|
15
|
+
available = executable && ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |path|
|
|
16
|
+
file = File.join(path, executable)
|
|
17
|
+
File.file?(file) && File.executable?(file)
|
|
18
|
+
end
|
|
19
|
+
requirement.merge("variant" => slot.fetch("id"), "status" => executable ? (available ? "available" : "missing") : "agent_check")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
def copy_tree(source, target, boundary, ancestors)
|
|
24
|
+
actual = File.realpath(source)
|
|
25
|
+
unless actual == boundary || actual.start_with?(boundary + "/")
|
|
26
|
+
raise Error, "Skill resource link escapes pinned repository: #{source}"
|
|
27
|
+
end
|
|
28
|
+
if File.directory?(actual)
|
|
29
|
+
raise Error, "Cyclic skill resource link: #{source}" if ancestors.include?(actual)
|
|
30
|
+
FileUtils.mkdir_p(target)
|
|
31
|
+
Dir.children(actual).each { |name| copy_tree(File.join(actual, name), File.join(target, name), boundary, ancestors + [actual]) }
|
|
32
|
+
elsif File.file?(actual)
|
|
33
|
+
FileUtils.cp(actual, target, preserve: true)
|
|
34
|
+
else
|
|
35
|
+
raise Error, "Unsupported skill resource: #{source}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
def snapshot(root, preset)
|
|
39
|
+
preset.fetch("variants").each do |slot|
|
|
40
|
+
Array(slot["skills"]).each_with_index do |spec, index|
|
|
41
|
+
revision = spec.fetch("revision")
|
|
42
|
+
raise Error, "Skill revision must be a full commit SHA" unless /\A[0-9a-f]{40}\z/.match?(revision)
|
|
43
|
+
target = File.join(root, "skills", slot.fetch("id"), index.to_s)
|
|
44
|
+
FileUtils.mkdir_p(target)
|
|
45
|
+
Dir.mktmpdir("ace-design-skill") do |checkout|
|
|
46
|
+
runtime = Runtime.new(@config)
|
|
47
|
+
commands = [["git", "init", "--quiet", checkout],
|
|
48
|
+
["git", "-C", checkout, "fetch", "--quiet", "--depth=1", spec.fetch("source"), revision],
|
|
49
|
+
["git", "-C", checkout, "checkout", "--quiet", "--detach", "FETCH_HEAD"]]
|
|
50
|
+
commands.each do |command|
|
|
51
|
+
result = runtime.capture(*command, timeout: 120)
|
|
52
|
+
raise Error, "Skill fetch failed: #{result['stderr']}" unless result["exit_code"] == 0
|
|
53
|
+
end
|
|
54
|
+
result = runtime.capture("git", "-C", checkout, "rev-parse", "HEAD", timeout: 10)
|
|
55
|
+
raise Error, "Skill revision mismatch" unless result["stdout"].strip == revision
|
|
56
|
+
FileUtils.rm_rf(File.join(checkout, ".git"))
|
|
57
|
+
copy_tree(checkout, target, File.realpath(checkout), [])
|
|
58
|
+
end
|
|
59
|
+
entry = spec.fetch("entry")
|
|
60
|
+
resolved = File.expand_path(entry, target)
|
|
61
|
+
unless resolved.start_with?(target + "/") && File.file?(resolved)
|
|
62
|
+
raise Error, "Missing skill entry: #{entry}"
|
|
63
|
+
end
|
|
64
|
+
spec["snapshot"] = "skills/#{slot.fetch('id')}/#{index}/#{entry}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|