space-architect 2.0.0.rc1 → 2.0.0.rc2

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.
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+ require "dry/validation/extensions/monads"
5
+ require "dry/monads"
6
+
7
+ module Space::Architect
8
+ # Validates the structured gates block parsed from an iteration's
9
+ # ## Acceptance Criteria section. Returns a Dry::Monads::Result:
10
+ # Success(gates) — well-formed (or empty list)
11
+ # Failure([messages]) — aggregated lint errors
12
+ class GateLint
13
+ include Dry::Monads[:result]
14
+
15
+ class GateContract < Dry::Validation::Contract
16
+ VALID_OPS = %w[>= <= > < == !=].freeze
17
+
18
+ json do
19
+ config.validate_keys = true
20
+
21
+ required(:id).filled(:string)
22
+ required(:ac).filled(:string)
23
+ required(:cmd).filled(:string)
24
+ optional(:cwd).maybe(:string)
25
+ optional(:timeout)
26
+ required(:expect).hash do
27
+ optional(:exit_code)
28
+ optional(:stdout_match).filled(:string)
29
+ optional(:threshold).hash do
30
+ required(:match).filled(:string)
31
+ required(:op).filled(:string)
32
+ required(:value)
33
+ end
34
+ end
35
+ end
36
+
37
+ rule(:cmd) { key.failure("must not be blank") if values[:cmd].is_a?(String) && values[:cmd].strip.empty? }
38
+ rule(:id) { key.failure("must not be blank") if values[:id].is_a?(String) && values[:id].strip.empty? }
39
+ rule(:ac) { key.failure("must not be blank") if values[:ac].is_a?(String) && values[:ac].strip.empty? }
40
+
41
+ rule(:expect) do
42
+ next unless values[:expect].is_a?(Hash)
43
+ known = %i[exit_code stdout_match threshold]
44
+ key.failure("must have at least one of: #{known.join(", ")}") if known.none? { |k| values[:expect].key?(k) }
45
+ end
46
+
47
+ rule("expect.exit_code") do
48
+ val = values.dig(:expect, :exit_code)
49
+ key.failure("must be an Integer") if !val.nil? && !val.is_a?(Integer)
50
+ end
51
+
52
+ rule("expect.threshold.op") do
53
+ val = values.dig(:expect, :threshold, :op)
54
+ key.failure("must be one of: #{VALID_OPS.join(", ")}") if val.is_a?(String) && !VALID_OPS.include?(val)
55
+ end
56
+
57
+ rule("expect.threshold.match") do
58
+ val = values.dig(:expect, :threshold, :match)
59
+ next unless val.is_a?(String)
60
+ begin
61
+ n = Regexp.new("#{val}|(?:)").match("").captures.size
62
+ key.failure("must contain exactly one capture group (found #{n})") unless n == 1
63
+ rescue RegexpError => e
64
+ key.failure("invalid regexp: #{e.message}")
65
+ end
66
+ end
67
+
68
+ rule("expect.threshold.value") do
69
+ val = values.dig(:expect, :threshold, :value)
70
+ key.failure("must be a Number") if !val.nil? && !val.is_a?(Numeric)
71
+ end
72
+
73
+ rule(:timeout) do
74
+ val = values[:timeout]
75
+ key.failure("must be a positive number of seconds") if !val.nil? && (!val.is_a?(Numeric) || val <= 0)
76
+ end
77
+ end
78
+
79
+ def self.call(gates) = new.call(gates)
80
+
81
+ def call(gates)
82
+ return Success([]) if gates.nil? || (gates.is_a?(Array) && gates.empty?)
83
+ return Failure(["gates block must be a YAML list, got #{gates.class}"]) unless gates.is_a?(Array)
84
+
85
+ errors = []
86
+ seen_ids = {}
87
+ contract = GateContract.new
88
+
89
+ gates.each.with_index(1) do |gate, i|
90
+ pfx = "gate[#{i}]"
91
+ unless gate.is_a?(Hash)
92
+ errors << "#{pfx}: must be a hash, got #{gate.class}"
93
+ next
94
+ end
95
+
96
+ result = contract.call(gate)
97
+ flatten_errors(result.errors.to_h, pfx, errors) unless result.success?
98
+
99
+ id = gate["id"] || gate[:id]
100
+ id = (id.is_a?(String) && !id.strip.empty?) ? id : nil
101
+ next unless id
102
+
103
+ if seen_ids.key?(id)
104
+ errors << "#{pfx}: duplicate id '#{id}' (also at gate[#{seen_ids[id]}])"
105
+ else
106
+ seen_ids[id] = i
107
+ end
108
+ end
109
+
110
+ errors.empty? ? Success(gates) : Failure(errors)
111
+ end
112
+
113
+ private
114
+
115
+ # Recursively flatten the nested errors hash from dry-validation into
116
+ # a flat Array<String>, translating "is not allowed" into "unknown key: <name>".
117
+ def flatten_errors(node, prefix, acc)
118
+ case node
119
+ when Array
120
+ node.each do |item|
121
+ case item
122
+ when String
123
+ if item == "is not allowed"
124
+ *parent_parts, key_name = prefix.split(".")
125
+ acc << "#{parent_parts.join(".")}: unknown key: #{key_name}"
126
+ else
127
+ acc << "#{prefix}: #{item}"
128
+ end
129
+ when Array, Hash
130
+ flatten_errors(item, prefix, acc)
131
+ end
132
+ end
133
+ when Hash
134
+ node.each do |k, v|
135
+ flatten_errors(v, "#{prefix}.#{k}", acc)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -54,7 +54,9 @@ module Space::Architect
54
54
  @disallowed_tools = disallowed_tools
55
55
  end
56
56
 
57
- def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil)
57
+ TIMEOUT_EXIT_CODE = 124
58
+
59
+ def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil)
58
60
  prompt_path = Pathname.new(prompt_path)
59
61
  run_log_path = Pathname.new(run_log_path)
60
62
 
@@ -65,9 +67,28 @@ module Space::Architect
65
67
  child = Async::Process::Child.new(*argv, chdir: chdir.to_s, in: prompt_io, out: w, err: log)
66
68
  w.close
67
69
  tasks = start_tee(r, log, push_url: push_url, push_token: push_token, push_client: push_client)
70
+ timed_out = false
71
+ timeout_task = nil
72
+
73
+ # Async::Task#with_timeout cannot do TERM→grace→KILL because
74
+ # Async::Process::Child#wait_thread's ensure goes straight to KILL.
75
+ # Instead: a concurrent fiber fires after the deadline and escalates.
76
+ # transient: true so the reactor doesn't wait for it when main work finishes.
77
+ if timeout && timeout > 0
78
+ timeout_task = Async(transient: true) do
79
+ sleep timeout
80
+ timed_out = true
81
+ Process.kill("TERM", -child.pid) rescue nil
82
+ sleep 0.5
83
+ Process.kill("KILL", -child.pid) rescue nil
84
+ end
85
+ end
86
+
68
87
  status = child.wait
88
+ timeout_task&.stop
89
+
69
90
  tasks.each(&:wait)
70
- status.exitstatus
91
+ timed_out ? TIMEOUT_EXIT_CODE : status.exitstatus
71
92
  end
72
93
  end
73
94
  end
@@ -193,7 +214,7 @@ module Space::Architect
193
214
  cfg
194
215
  end
195
216
 
196
- def run(prompt_path:, run_log_path:, chdir:)
217
+ def run(prompt_path:, run_log_path:, chdir:, timeout: nil) # timeout: deferred — opencode kill path is out of scope
197
218
  prompt_path = Pathname.new(prompt_path)
198
219
  run_log_path = Pathname.new(run_log_path)
199
220
  config_path = write_config
@@ -2,11 +2,11 @@
2
2
 
3
3
  > Cross-iteration table of contents for the Architect Loop. Per-iteration detail lives in
4
4
  > architecture/I<NN>-<iteration>.md — this file only indexes the iterations and carries
5
- > mission-wide state. Keep it short (~150 lines): the next session must grok it
5
+ > project-wide state. Keep it short (~150 lines): the next session must grok it
6
6
  > in under a minute. Not in the committed architecture = didn't happen.
7
7
  >
8
- > Durable mission contract: `architecture/BRIEF.md` (numbered §sections, cited as BRIEF §N).
9
- > Create it with `architect brief new`. Edits to a §section are mission-scope decisions —
8
+ > Durable project contract: `architecture/BRIEF.md` (numbered §sections, cited as BRIEF §N).
9
+ > Create it with `architect brief new`. Edits to a §section are project-scope decisions —
10
10
  > log them in the Decisions log below, never as silent per-iteration drift.
11
11
 
12
12
  ## TL;DR (keep current)
@@ -34,13 +34,24 @@
34
34
 
35
35
  ## Iteration index
36
36
 
37
- <!-- Add a row per iteration. Create iterations with `architect new <name>`. -->
37
+ <!-- Rows are added here only for SCAFFOLDED iterations. `architect new <name>` allocates
38
+ the ordinal at spec-time and scaffolds architecture/I<NN>-<name>.md. Do NOT
39
+ pre-number planned work — un-numbered planned items live in the Backlog below. -->
38
40
 
39
41
  | I# | Iteration | Status | freeze_sha | Integration branch | Verdict | File |
40
42
  |----|-----------|--------|-----------|--------------------|---------|------|
41
43
 
42
44
  Status values: speccing → frozen → dispatched → in-flight → awaiting-verdict → done.
43
45
 
46
+ ## Backlog (ordered; no ordinal until `architect new`)
47
+
48
+ <!-- Next-up first. Each item gets its ordinal only when it is about to be specced:
49
+ run `architect new <name>` at that point to allocate the ordinal at spec-time and
50
+ scaffold the iteration file. Pre-numbering planned work forces renumber churn every
51
+ time priorities reshuffle. -->
52
+
53
+ - _[next planned item]_
54
+
44
55
  ## Open items for the human / architect
45
56
 
46
57
  <!-- Blocking items: unresolved disagreements (which iteration), scope questions,
@@ -1,11 +1,11 @@
1
1
  # BRIEF — <%= @_title %>
2
2
 
3
- > Durable mission contract for the Architect Loop. The numbered §sections below are the
3
+ > Durable project contract for the Architect Loop. The numbered §sections below are the
4
4
  > stable, cross-iteration address space: every iteration cites them as **BRIEF §N** in its
5
5
  > Grounds, Specification, Acceptance Criteria, and Verdict (e.g. `(BRIEF §3.1)`), the way each
6
- > gate addresses its intent back to one frozen reference. Frozen early; edits are mission-scope
6
+ > gate addresses its intent back to one frozen reference. Frozen early; edits are project-scope
7
7
  > decisions logged in ARCHITECT.md's Decisions log, never silent per-iteration drift. Optional —
8
- > a discovery mission may defer this and cite per-iteration Grounds until the shape stabilizes,
8
+ > a discovery project may defer this and cite per-iteration Grounds until the shape stabilizes,
9
9
  > then promote the consolidated picture here once.
10
10
 
11
11
  ## 1. Goal & non-goals
@@ -38,6 +38,6 @@ frozen proof. An iteration's Specification cites its row here (e.g. "BRIEF §5 S
38
38
  <!-- Known risks and the things a builder's PHASE 0 should challenge — cited from specs as
39
39
  "BRIEF §6". -->
40
40
 
41
- ## 7. Definition of done (whole mission)
41
+ ## 7. Definition of done (whole project)
42
42
 
43
- <!-- The mission-level DoD. Iteration gates diff against "BRIEF §7 DoD". -->
43
+ <!-- The project-level DoD. Iteration gates diff against "BRIEF §7 DoD". -->
@@ -32,16 +32,27 @@ Write + commit: `architect section <%= @_name %> specification --from <file>`. -
32
32
 
33
33
  ## Acceptance Criteria
34
34
 
35
- <!-- PROOF. Exact gate commands + thresholds. `architect freeze <%= @_name %>`
36
- commits this file and records its SHA as freeze_sha. Read-only afterward — any
37
- change to Grounds/Specification/Acceptance Criteria = automatic iteration FAIL. -->
35
+ <!-- PROOF. Write the prose conditions of correctness (AC1, AC2, …) that the
36
+ architect judges against. Runnable checks live in the fenced ```gates block below
37
+ (parsed at freeze time absent or empty is allowed; malformed fails freeze).
38
+ `architect freeze <%= @_name %>` commits this file and records its SHA as
39
+ freeze_sha. Read-only afterward — any change to Grounds/Specification/Acceptance
40
+ Criteria = automatic iteration FAIL. -->
38
41
 
39
42
  > Gate-pass is necessary, not sufficient: the architect also reads the diff against the
40
43
  > cited **BRIEF §sections** intent and the §1 cardinal invariant before the verdict.
41
44
 
42
- | AC# | Command | Threshold | Brief § |
43
- |-----|---------|-----------|---------|
44
- | | | | |
45
+ **AC1.** ...
46
+
47
+ ```gates
48
+ # Each gate backs one prose AC above. Remove the comment markers to activate.
49
+ # - id: suite-green # unique slug within the iteration (required)
50
+ # ac: AC1 # which prose AC this gate backs (required)
51
+ # cwd: repos/my-repo # run dir, relative to space root (optional; consumed by I03)
52
+ # cmd: bundle exec rake test # shell command (required, non-empty)
53
+ # expect: # at least one of: exit_code, stdout_match, threshold
54
+ # exit_code: 0
55
+ ```
45
56
 
46
57
  ## Builder Prompt
47
58
 
@@ -6,7 +6,9 @@ require "space_src"
6
6
  require_relative "space_architect/harness"
7
7
  require_relative "space_architect/run_creator"
8
8
  require_relative "space_architect/dispatcher"
9
- require_relative "space_architect/architect_mission"
9
+ require_relative "space_architect/gate_lint"
10
+ require_relative "space_architect/gate_evaluator"
11
+ require_relative "space_architect/architect_project"
10
12
  require_relative "space_architect/skill_installer"
11
13
  require_relative "space_architect/research"
12
14
  require_relative "space_architect/cli"
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../oci_packer"
4
+ require_relative "../oci_builder"
5
+
6
+ module Space::Core::CLI
7
+ class Build < BaseCommand
8
+ desc "Build (and tag) the OCI image for the current space"
9
+
10
+ def call(**opts)
11
+ setup_terminal(**opts.slice(:color, :colors))
12
+ handle_errors do
13
+ result = store.current.bind do |space|
14
+ out_dir = space.path.join("build", "oci")
15
+ Space::Core::OciPacker.new(space: space, output_dir: out_dir).generate.bind do
16
+ Space::Core::OciBuilder.new(space: space, output_dir: out_dir).command
17
+ end
18
+ end
19
+ render(result) do |argv|
20
+ terminal.say "Building: #{argv.join(' ')}"
21
+ out.flush
22
+ Kernel.exec(*argv)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -16,7 +16,14 @@ module Space::Core::CLI
16
16
  # output stay plain. The `src` binary never loads space_core, so its own plain
17
17
  # Usage is untouched.
18
18
  module Help
19
- TAGLINE = "date-prefixed workspaces; repos provisioned on fibers at copy-on-write speed"
19
+ # One tagline per binary the root header is served for both `space` and
20
+ # `architect` from here, so we pick by $PROGRAM_NAME. The default covers the
21
+ # test runner and any other invocation where the binary can't be identified.
22
+ TAGLINES = {
23
+ "space" => "date-prefixed workspaces; repos provisioned on fibers at copy-on-write speed",
24
+ "architect" => "the Architect Loop: structured judgment plus a fleet of headless AI builders"
25
+ }.freeze
26
+ DEFAULT_TAGLINE = TAGLINES.fetch("space")
20
27
 
21
28
  module_function
22
29
 
@@ -39,7 +46,13 @@ module Space::Core::CLI
39
46
  return unless result.names.empty?
40
47
 
41
48
  "#{pastel.bold.cyan("space-architect")} #{pastel.dim(Space::Core::VERSION)} " \
42
- "#{pastel.dim("— #{TAGLINE}")}\n"
49
+ "#{pastel.dim("— #{tagline}")}\n"
50
+ end
51
+
52
+ # The tagline for the binary in hand: `space` and `architect` each get their
53
+ # own, keyed off $PROGRAM_NAME (the same signal program_prefix reads).
54
+ def tagline
55
+ TAGLINES.fetch(File.basename($PROGRAM_NAME), DEFAULT_TAGLINE)
43
56
  end
44
57
 
45
58
  def footer(result, pastel)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../oci_packer"
5
+
6
+ module Space::Core::CLI
7
+ class Pack < BaseCommand
8
+ desc "Generate a portable OCI build context for the current space"
9
+ option :output, aliases: ["-o"], type: :string, default: nil,
10
+ desc: "Output directory (default: build/oci/ under the space root)"
11
+
12
+ def call(output: nil, **opts)
13
+ setup_terminal(**opts.slice(:color, :colors))
14
+ handle_errors do
15
+ result = store.current.bind do |space|
16
+ out_dir = output ? Pathname.new(output).expand_path : space.path.join("build", "oci")
17
+ Space::Core::OciPacker.new(space: space, output_dir: out_dir).generate
18
+ .fmap { |dir| { space: space, dir: dir } }
19
+ end
20
+ render(result) do |r|
21
+ terminal.success "Generated OCI context: #{r[:dir]}"
22
+ terminal.say "Build: docker build -f #{r[:dir]}/Dockerfile -t #{r[:space].id}:latest ."
23
+ terminal.say " (run from: #{r[:space].path})"
24
+ CLI.record_outcome(Outcome.new(exit_code: 0))
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -3,7 +3,7 @@
3
3
  module Space::Core::CLI
4
4
  module Repo
5
5
  class Add < BaseCommand
6
- desc "Clone repos into the current space"
6
+ desc "Add repos to the current space (copy-on-write from an evergreen checkout when available, else clone)"
7
7
  argument :repos, type: :array, required: false, desc: "REPO [REPO...]"
8
8
 
9
9
  def call(repos: [], **opts)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../oci_runner"
5
+
6
+ module Space::Core::CLI
7
+ class Run < BaseCommand
8
+ desc "Run the packed OCI image for the current space (auth injected at runtime)"
9
+
10
+ argument :command, type: :array, required: false, desc: "Command to run in the container (default: login shell)"
11
+ option :tty, type: :boolean, default: nil, desc: "Force interactive TTY (default: auto-detect)"
12
+
13
+ def call(command: [], tty: nil, **opts)
14
+ setup_terminal(**opts.slice(:color, :colors))
15
+ handle_errors do
16
+ result = store.current.bind do |space|
17
+ runner = Space::Core::OciRunner.new(space: space, interactive: tty.nil? ? CLI.tty?(out) : tty)
18
+ runner.command(command).fmap { |argv| { argv: argv, runner: runner } }
19
+ end
20
+ render(result) do |r|
21
+ r[:runner].host_dirs.each { |d| FileUtils.mkdir_p(d) }
22
+ terminal.say "Running: #{r[:argv].join(' ')}"
23
+ out.flush # Kernel.exec replaces the process without flushing buffered IO
24
+ Kernel.exec(*r[:argv])
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -137,6 +137,9 @@ require_relative "cli/status"
137
137
  require_relative "cli/config"
138
138
  require_relative "cli/repo"
139
139
  require_relative "cli/shell"
140
+ require_relative "cli/pack"
141
+ require_relative "cli/build"
142
+ require_relative "cli/run"
140
143
 
141
144
  Space::Core::CLI::Registry.register "init", Space::Core::CLI::Init
142
145
  Space::Core::CLI::Registry.register "new", Space::Core::CLI::New
@@ -169,3 +172,6 @@ Space::Core::CLI::Registry.register "shell" do |s|
169
172
  s.register "fish", Space::Core::CLI::Shell::Fish
170
173
  s.register "complete", Space::Core::CLI::Shell::Complete
171
174
  end
175
+ Space::Core::CLI::Registry.register "pack", Space::Core::CLI::Pack
176
+ Space::Core::CLI::Registry.register "build", Space::Core::CLI::Build
177
+ Space::Core::CLI::Registry.register "run", Space::Core::CLI::Run
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+ require "dry/monads"
6
+
7
+ module Space::Core
8
+ class OciBuilder
9
+ include Dry::Monads[:result]
10
+
11
+ def initialize(space:, output_dir:)
12
+ @space = space
13
+ @output_dir = Pathname.new(output_dir)
14
+ end
15
+
16
+ def version
17
+ compute_version.value!
18
+ end
19
+
20
+ def image
21
+ "#{space.id}:#{version}"
22
+ end
23
+
24
+ def latest
25
+ "#{space.id}:latest"
26
+ end
27
+
28
+ def command
29
+ compute_version.fmap do |ver|
30
+ [
31
+ "container", "build",
32
+ "-f", @output_dir.join("Dockerfile").to_s,
33
+ "-t", "#{space.id}:#{ver}",
34
+ "-t", latest,
35
+ space.path.to_s
36
+ ]
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :space
43
+
44
+ def compute_version
45
+ sha_out, _, sha_status = Open3.capture3("git", "-C", space.path.to_s, "rev-parse", "--short=12", "HEAD")
46
+ unless sha_status.success?
47
+ return Failure("space is not a git repository with a commit; cannot compute a version tag")
48
+ end
49
+
50
+ sha = sha_out.strip
51
+ status_out, _, _ = Open3.capture3("git", "-C", space.path.to_s, "status", "--porcelain")
52
+ dirty = !status_out.strip.empty?
53
+ Success(dirty ? "#{sha}-dirty" : sha)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "pathname"
6
+ require "dry/monads"
7
+
8
+ module Space::Core
9
+ class OciPacker
10
+ include Dry::Monads[:result]
11
+
12
+ TEMPLATE_DIR = Pathname.new(__dir__).join("templates", "oci").freeze
13
+
14
+ def initialize(space:, output_dir:)
15
+ @space = space
16
+ @output_dir = Pathname.new(output_dir)
17
+ end
18
+
19
+ def generate
20
+ FileUtils.mkdir_p(@output_dir)
21
+ validated = validate_provision_scripts(space.provision_scripts)
22
+ return validated if validated.failure?
23
+ validated = validate_persist_paths(space.persist_paths)
24
+ return validated if validated.failure?
25
+ write("Dockerfile", render_template("dockerfile.erb"))
26
+ write("entrypoint.sh", entrypoint_content, mode: 0o755)
27
+ write("Dockerfile.dockerignore", render_template("dockerignore.erb"))
28
+ Success(@output_dir)
29
+ rescue StandardError => e
30
+ Failure(e)
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :space, :output_dir
36
+
37
+ def space_id
38
+ @space.id
39
+ end
40
+
41
+ def repos
42
+ @space.repos
43
+ end
44
+
45
+ def provision_scripts
46
+ @space.provision_scripts
47
+ end
48
+
49
+ def persist_paths
50
+ @space.persist_paths
51
+ end
52
+
53
+ def validate_provision_scripts(scripts)
54
+ scripts.each do |rel_path|
55
+ if Pathname.new(rel_path).absolute?
56
+ return Failure("provision script '#{rel_path}' must not be an absolute path")
57
+ end
58
+
59
+ resolved = space.path.join(rel_path).cleanpath
60
+ space_root = space.path.cleanpath
61
+ unless resolved.to_s.start_with?("#{space_root}/") || resolved == space_root
62
+ return Failure("provision script '#{rel_path}' escapes the space root")
63
+ end
64
+
65
+ return Failure("provision script '#{rel_path}' does not exist under the space root") unless resolved.exist?
66
+ end
67
+ Success(scripts)
68
+ end
69
+
70
+ def validate_persist_paths(paths)
71
+ paths.each do |path|
72
+ unless Pathname.new(path).absolute?
73
+ return Failure("persist path '#{path}' must be an absolute path")
74
+ end
75
+ end
76
+ Success(paths)
77
+ end
78
+
79
+ def entrypoint_content
80
+ @entrypoint_content ||= render_template("entrypoint.sh.erb")
81
+ end
82
+
83
+ def entrypoint_b64
84
+ [entrypoint_content].pack("m0")
85
+ end
86
+
87
+ def render_template(name)
88
+ template_path = TEMPLATE_DIR.join(name)
89
+ ERB.new(template_path.read, trim_mode: "-").result(binding)
90
+ end
91
+
92
+ def write(filename, content, mode: 0o644)
93
+ path = @output_dir.join(filename)
94
+ File.write(path, content)
95
+ File.chmod(mode, path)
96
+ path
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "dry/monads"
5
+
6
+ module Space::Core
7
+ class OciRunner
8
+ include Dry::Monads[:result]
9
+
10
+ AUTH_ENV = %w[ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_BASE_URL].freeze
11
+
12
+ def initialize(space:, env: ENV.to_h, interactive: true)
13
+ @space = space
14
+ @env = env
15
+ @interactive = interactive
16
+ end
17
+
18
+ def image
19
+ "#{space.id}:latest"
20
+ end
21
+
22
+ def mounts
23
+ space.persist_paths.map do |guest|
24
+ [space.path.join(".state" + guest), guest]
25
+ end
26
+ end
27
+
28
+ def host_dirs
29
+ mounts.map(&:first)
30
+ end
31
+
32
+ def command(extra = [])
33
+ validated = validate_persist_paths(space.persist_paths)
34
+ return validated if validated.failure?
35
+
36
+ argv = [
37
+ "container", "run", "--rm",
38
+ *(@interactive ? ["-i", "-t"] : []),
39
+ *auth_flags,
40
+ *mount_flags,
41
+ image,
42
+ *extra
43
+ ]
44
+ Success(argv)
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :space, :env, :interactive
50
+
51
+ def auth_flags
52
+ AUTH_ENV.each_with_object([]) do |var, flags|
53
+ val = env[var]
54
+ flags.push("-e", var) if val && !val.empty?
55
+ end
56
+ end
57
+
58
+ def mount_flags
59
+ mounts.each_with_object([]) do |(host, guest), flags|
60
+ flags.push("-v", "#{host}:#{guest}")
61
+ end
62
+ end
63
+
64
+ def validate_persist_paths(paths)
65
+ paths.each do |path|
66
+ unless Pathname.new(path).absolute?
67
+ return Failure("persist path '#{path}' must be an absolute path")
68
+ end
69
+ end
70
+ Success(paths)
71
+ end
72
+ end
73
+ end