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.
- checksums.yaml +4 -4
- data/README.md +34 -18
- data/lib/space_architect/{architect_mission.rb → architect_project.rb} +396 -77
- data/lib/space_architect/cli/architect.rb +170 -60
- data/lib/space_architect/cli/research.rb +1 -1
- data/lib/space_architect/gate_evaluator.rb +65 -0
- data/lib/space_architect/gate_lint.rb +140 -0
- data/lib/space_architect/harness.rb +24 -3
- data/lib/space_architect/templates/architect.md.erb +15 -4
- data/lib/space_architect/templates/brief.md.erb +5 -5
- data/lib/space_architect/templates/iteration.md.erb +17 -6
- data/lib/space_architect.rb +3 -1
- data/lib/space_core/cli/build.rb +27 -0
- data/lib/space_core/cli/help.rb +15 -2
- data/lib/space_core/cli/pack.rb +29 -0
- data/lib/space_core/cli/repo.rb +1 -1
- data/lib/space_core/cli/run.rb +29 -0
- data/lib/space_core/cli.rb +6 -0
- data/lib/space_core/oci_builder.rb +56 -0
- data/lib/space_core/oci_packer.rb +99 -0
- data/lib/space_core/oci_runner.rb +73 -0
- data/lib/space_core/space.rb +10 -2
- data/lib/space_core/space_store.rb +1 -1
- data/lib/space_core/templates/oci/dockerfile.erb +63 -0
- data/lib/space_core/templates/oci/dockerignore.erb +17 -0
- data/lib/space_core/templates/oci/entrypoint.sh.erb +10 -0
- data/lib/space_core/version.rb +1 -1
- data/skill/architect/SKILL.md +109 -53
- data/skill/architect/dispatch.md +147 -39
- data/skill/architect/research.md +1 -1
- data/skill/architect-research/SKILL.md +2 -2
- data/skill/architect-vocabulary/SKILL.md +24 -21
- metadata +13 -2
|
@@ -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
|
-
|
|
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
|
-
>
|
|
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
|
|
9
|
-
> Create it with `architect brief new`. Edits to a §section are
|
|
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
|
-
<!--
|
|
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
|
|
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
|
|
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
|
|
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
|
|
41
|
+
## 7. Definition of done (whole project)
|
|
42
42
|
|
|
43
|
-
<!-- The
|
|
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.
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/space_architect.rb
CHANGED
|
@@ -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/
|
|
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
|
data/lib/space_core/cli/help.rb
CHANGED
|
@@ -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
|
-
|
|
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("— #{
|
|
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
|
data/lib/space_core/cli/repo.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Space::Core::CLI
|
|
4
4
|
module Repo
|
|
5
5
|
class Add < BaseCommand
|
|
6
|
-
desc "
|
|
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
|
data/lib/space_core/cli.rb
CHANGED
|
@@ -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
|