space-architect 9.2.1 → 9.4.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.
@@ -1,147 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async/http/client"
4
- require "async/http/endpoint"
5
- require "json"
6
-
7
- module Space::Architect
8
- # HTTP client for the space-server jobs API, modeled on RunCreator: Bearer
9
- # auth, injectable async-http client, Space::Core::Error on any failure.
10
- class JobsClient
11
- # Poll bound for #wait_for_run_id: 2s * 30 attempts = 60s. Both are
12
- # injectable kwargs so tests need not sleep real seconds.
13
- POLL_INTERVAL_SECONDS = 2
14
- POLL_MAX_ATTEMPTS = 30
15
-
16
- def initialize(host, token, client: nil)
17
- @host = host.chomp("/")
18
- @token = token
19
- @client = client
20
- end
21
-
22
- # GET /jobs — returns the "jobs" array (owner-scoped, newest-first).
23
- def list
24
- get_json("/jobs")["jobs"]
25
- end
26
-
27
- # GET /jobs/:id — returns the job JSON.
28
- def show(id)
29
- get_json("/jobs/#{id}")
30
- end
31
-
32
- # POST /jobs — submits a job spec (harness/prompt/environment) and returns the
33
- # created job's integer id. Raises Space::Core::Error on any failure, modeled on
34
- # RunCreator#create (same 20x-status + integer-id checks).
35
- def create(spec)
36
- Sync do
37
- with_client do |c|
38
- response = c.post("/jobs", headers: headers, body: JSON.generate(spec))
39
- parsed = parse_json(response, "POST /jobs", expected_status: 201)
40
- id = parsed["id"]
41
- raise Space::Core::Error, "POST /jobs: missing or non-integer id in response: #{parsed.inspect[0, 200]}" \
42
- unless id.is_a?(Integer)
43
-
44
- id
45
- end
46
- end
47
- end
48
-
49
- # POST /jobs/:id/cancel — returns {"id"=>.., "status"=>"canceled"}.
50
- def cancel(id)
51
- Sync do
52
- with_client do |c|
53
- response = c.post("/jobs/#{id}/cancel", headers: headers, body: nil)
54
- parse_json(response, "POST /jobs/#{id}/cancel")
55
- end
56
- end
57
- end
58
-
59
- # GET /runs/:id/stream — yields each SSE event's data: payload to the
60
- # block as it arrives. Returns when the stream reports run_complete or
61
- # closes cleanly. Raises Space::Core::Error on a non-200 response.
62
- def stream(run_id, &block)
63
- Sync do
64
- with_client do |c|
65
- response = c.get("/runs/#{run_id}/stream", headers: headers)
66
- unless response.status == 200
67
- body = response.read || ""
68
- raise Space::Core::Error, "GET /runs/#{run_id}/stream failed (#{response.status}): #{body[0, 200]}"
69
- end
70
-
71
- read_sse(response, &block)
72
- end
73
- end
74
- end
75
-
76
- # Polls GET /jobs/:id until run_id is present, waiting `interval` seconds
77
- # between attempts, up to `attempts` tries. Raises Space::Core::Error if
78
- # the bound is exceeded.
79
- def wait_for_run_id(id, interval: POLL_INTERVAL_SECONDS, attempts: POLL_MAX_ATTEMPTS)
80
- attempts.times do |i|
81
- run_id = show(id)["run_id"]
82
- return run_id if run_id
83
- sleep interval unless i == attempts - 1
84
- end
85
- raise Space::Core::Error, "job #{id}: no run_id after #{attempts} attempts (#{attempts * interval}s)"
86
- end
87
-
88
- private
89
-
90
- def get_json(path)
91
- Sync do
92
- with_client do |c|
93
- response = c.get(path, headers: headers)
94
- parse_json(response, "GET #{path}")
95
- end
96
- end
97
- end
98
-
99
- def with_client
100
- if @client
101
- yield @client
102
- else
103
- Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) { |c| yield c }
104
- end
105
- end
106
-
107
- def headers
108
- [
109
- ["authorization", "Bearer #{@token}"],
110
- ["content-type", "application/json"]
111
- ]
112
- end
113
-
114
- def parse_json(response, what, expected_status: 200)
115
- status = response.status
116
- body = response.read || ""
117
- raise Space::Core::Error, "#{what} failed (#{status}): #{body[0, 200]}" unless status == expected_status
118
-
119
- JSON.parse(body)
120
- end
121
-
122
- # Buffers chunks and splits on the SSE event terminator ("\n\n"). Each
123
- # event's data: line(s) are joined and yielded; an event whose data JSON
124
- # carries type == "run_complete" ends the stream.
125
- def read_sse(response)
126
- buffer = +""
127
- response.each do |chunk|
128
- buffer << chunk
129
- while (boundary = buffer.index("\n\n"))
130
- event = buffer.slice!(0..boundary + 1)
131
- data = event.each_line.select { |line| line.start_with?("data:") }
132
- .map { |line| line.sub(/\Adata:\s?/, "").chomp }
133
- .join("\n")
134
- next if data.empty?
135
-
136
- yield data
137
- parsed = begin
138
- JSON.parse(data)
139
- rescue JSON::ParserError
140
- nil
141
- end
142
- return if parsed.is_a?(Hash) && parsed["type"] == "run_complete"
143
- end
144
- end
145
- end
146
- end
147
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async/http/client"
4
- require "async/http/endpoint"
5
- require "json"
6
-
7
- module Space::Architect
8
- class RunCreator
9
- def initialize(host, token, client: nil)
10
- @host = host.chomp("/")
11
- @token = token
12
- @client = client
13
- end
14
-
15
- # POSTs to /runs and returns the integer run id.
16
- # Raises Space::Core::Error on any failure — never returns nil.
17
- def create
18
- Sync do
19
- if @client
20
- response = @client.post("/runs", headers: headers, body: nil)
21
- parse_response(response)
22
- else
23
- Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) do |c|
24
- response = c.post("/runs", headers: headers, body: nil)
25
- parse_response(response)
26
- end
27
- end
28
- end
29
- end
30
-
31
- private
32
-
33
- def headers
34
- [
35
- ["authorization", "Bearer #{@token}"],
36
- ["content-type", "application/json"]
37
- ]
38
- end
39
-
40
- def parse_response(response)
41
- status = response.status
42
- body = response.read || ""
43
- raise Space::Core::Error, "POST /runs failed (#{status}): #{body[0, 200]}" unless status == 201
44
-
45
- parsed = JSON.parse(body)
46
- id = parsed["id"]
47
- raise Space::Core::Error, "POST /runs: missing or non-integer id in response: #{body[0, 200]}" \
48
- unless id.is_a?(Integer)
49
-
50
- id
51
- end
52
- end
53
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "open3"
4
-
5
- module Space::Architect
6
- module SessionSync
7
- # Resolves the absolute path to the `architect` executable the plist
8
- # should invoke, imitating RepoTender::CLI::Daemon::Helpers::Resolve's
9
- # detect_bin_path precedence: an env override (tests), the dev checkout's
10
- # exe/architect, `which architect`, then the installed gem's bin.
11
- module BinPath
12
- module_function
13
-
14
- def detect(env: ENV)
15
- override = env["SPACE_ARCHITECT_BIN_PATH"]
16
- return override if override && !override.empty?
17
-
18
- dev = File.expand_path("../../../exe/architect", __dir__)
19
- return dev if File.exist?(dev)
20
-
21
- out, _err, status = Open3.capture3("which", "architect")
22
- return out.strip if status.success? && !out.strip.empty?
23
-
24
- Gem.bin_path("space-architect", "architect")
25
- end
26
- end
27
- end
28
- end
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
- require "fileutils"
5
-
6
- module Space::Architect
7
- module SessionSync
8
- # Machine-managed cursor at $XDG_STATE_HOME/space-architect/session-sync.yaml
9
- # (imitates RepoTender::State::Store's load/write/emit shape). Keyed by
10
- # absolute source path; each entry records the size + mtime observed the
11
- # last time that file was uploaded.
12
- class Cursor
13
- Entry = Data.define(:size, :mtime) do
14
- def initialize(size:, mtime:) = super
15
- end
16
-
17
- def self.load(path)
18
- raw = read_yaml(path)
19
- raw.each_with_object({}) do |(k, v), acc|
20
- acc[k] = Entry.new(size: v["size"], mtime: v["mtime"])
21
- end
22
- end
23
-
24
- def self.write(path, entries)
25
- FileUtils.mkdir_p(File.dirname(path))
26
- payload = entries.each_with_object({}) { |(k, v), acc| acc[k] = {"size" => v.size, "mtime" => v.mtime} }
27
- tmp = "#{path}.tmp.#{Process.pid}"
28
- begin
29
- File.write(tmp, YAML.dump(payload, line_width: -1))
30
- File.rename(tmp, path)
31
- ensure
32
- File.delete(tmp) if File.exist?(tmp)
33
- end
34
- end
35
-
36
- def self.read_yaml(path)
37
- return {} unless File.exist?(path)
38
-
39
- YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
40
- end
41
- end
42
- end
43
- end
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Space::Architect
4
- module SessionSync
5
- # Hand-rolled launchd plist emitter for the session-sync agent, imitating
6
- # RepoTender::Launchd::Plist's shape. Under launchd's bare PATH, a bare
7
- # env-relative ruby binstub shebang resolves to macOS system Ruby instead
8
- # of the caller's toolchain, so ProgramArguments names the interpreter
9
- # explicitly (ruby_bin) rather than relying on shebang resolution — no
10
- # mise wrapping needed, since the sync itself pins no toolchain at run
11
- # time.
12
- class Plist
13
- DEFAULT_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
14
-
15
- HEADER = <<~XML
16
- <?xml version="1.0" encoding="UTF-8"?>
17
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
18
- <plist version="1.0">
19
- <dict>
20
- XML
21
- FOOTER = "</dict>\n</plist>\n"
22
-
23
- class << self
24
- # @param label [String] The job label (reverse-DNS; on-disk plist basename).
25
- # @param refresh_interval [Integer] StartInterval in seconds (must be > 0).
26
- # @param log_dir [String] Absolute directory for stdout/stderr logs.
27
- # @param bin_path [String] Absolute path to the architect bin script.
28
- # @param ruby_bin [String] Absolute path to the ruby to run bin_path under.
29
- # @param host [String] --host value passed to `sessions sync`.
30
- # @param env [Hash] EnvironmentVariables dict; must contain a non-empty
31
- # SessionSync::TOKEN_ENV value (resolved by the caller —
32
- # never resolved here). PATH defaults to DEFAULT_PATH
33
- # unless the caller supplies its own.
34
- # @return [String] the full plist XML.
35
- def call(label:, refresh_interval:, log_dir:, bin_path:, ruby_bin:, host:, env:)
36
- raise ArgumentError, "label is required" if label.to_s.empty?
37
- raise ArgumentError, "refresh_interval must be > 0" unless refresh_interval.is_a?(Integer) && refresh_interval > 0
38
- %w[log_dir bin_path ruby_bin].each do |k|
39
- v = binding.local_variable_get(k)
40
- raise ArgumentError, "#{k} must be absolute (got #{v.inspect})" unless v.is_a?(String) && File.absolute_path?(v)
41
- end
42
- raise ArgumentError, "host is required" if host.to_s.empty?
43
- raise ArgumentError, "env[#{SessionSync::TOKEN_ENV}] is required" if env.to_h[SessionSync::TOKEN_ENV].to_s.empty?
44
-
45
- out_log = File.join(log_dir, "#{label}.out.log")
46
- err_log = File.join(log_dir, "#{label}.err.log")
47
-
48
- body = +""
49
- body << key("Label") << string(label) << "\n"
50
- body << key("ProgramArguments") << "\n" << array([
51
- ruby_bin,
52
- bin_path,
53
- "sessions",
54
- "sync",
55
- "--host", host
56
- ])
57
- body << key("EnvironmentVariables") << "\n" << dict({"PATH" => DEFAULT_PATH}.merge(env.to_h))
58
- body << key("StartInterval") << integer(refresh_interval) << "\n"
59
- body << key("RunAtLoad") << boolean(true) << "\n"
60
- body << key("ProcessType") << string("Background") << "\n"
61
- body << key("StandardOutPath") << string(out_log) << "\n"
62
- body << key("StandardErrorPath") << string(err_log) << "\n"
63
-
64
- HEADER + body + FOOTER
65
- end
66
-
67
- private
68
-
69
- def key(name)
70
- " <key>#{escape(name)}</key>\n"
71
- end
72
-
73
- def escape(s)
74
- s.to_s
75
- .gsub("&", "&amp;")
76
- .gsub("<", "&lt;")
77
- .gsub(">", "&gt;")
78
- .gsub("\"", "&quot;")
79
- .gsub("'", "&apos;")
80
- end
81
-
82
- def string(s)
83
- " <string>#{escape(s)}</string>"
84
- end
85
-
86
- def integer(i)
87
- " <integer>#{Integer(i)}</integer>"
88
- end
89
-
90
- def boolean(b)
91
- " <#{b ? "true" : "false"}/>"
92
- end
93
-
94
- def array(items)
95
- out = +" <array>\n"
96
- items.each { |arg| out << " <string>#{escape(arg)}</string>\n" }
97
- out << " </array>"
98
- end
99
-
100
- def dict(hash)
101
- out = +" <dict>\n"
102
- hash.each do |k, v|
103
- out << " <key>#{escape(k)}</key>\n"
104
- out << " <string>#{escape(v)}</string>\n"
105
- end
106
- out << " </dict>"
107
- end
108
- end
109
- end
110
- end
111
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "cursor"
4
- require_relative "session_id"
5
-
6
- module Space::Architect
7
- module SessionSync
8
- # One sync pass: scans the pi + claude session roots, decides
9
- # upload/skip per the frozen cursor rules, uploads via the injected
10
- # client, and persists the updated cursor (unless dry_run).
11
- class Runner
12
- RECENT_MTIME_SECONDS = 60
13
-
14
- def initialize(client:, state_path:, pi_root:, claude_root:, now: -> { Time.now }, dry_run: false)
15
- @client = client
16
- @state_path = state_path
17
- @pi_root = pi_root
18
- @claude_root = claude_root
19
- @now = now
20
- @dry_run = dry_run
21
- end
22
-
23
- def call
24
- cursor = Cursor.load(@state_path)
25
- results = files.map { |path, session_id| process(path, session_id, cursor) }
26
- Cursor.write(@state_path, cursor) unless @dry_run
27
- results
28
- end
29
-
30
- private
31
-
32
- def process(path, session_id, cursor)
33
- stat = File.stat(path)
34
- recorded = cursor[path]
35
-
36
- return {path: path, session_id: session_id, action: :skipped, reason: "recent mtime"} if recent_mtime?(stat)
37
- return {path: path, session_id: session_id, action: :skipped, reason: "unchanged"} if recorded && recorded.size >= stat.size
38
-
39
- return {path: path, session_id: session_id, action: :would_upload} if @dry_run
40
-
41
- response = @client.upload(path: path, session_id: session_id)
42
- if [200, 201].include?(response[:status])
43
- cursor[path] = Cursor::Entry.new(size: stat.size, mtime: stat.mtime.to_i)
44
- {path: path, session_id: session_id, action: response[:status] == 201 ? :uploaded : :updated,
45
- conversation_id: response[:conversation_id]}
46
- else
47
- {path: path, session_id: session_id, action: :failed, status: response[:status], errors: response[:errors]}
48
- end
49
- end
50
-
51
- def recent_mtime?(stat)
52
- (@now.call - stat.mtime) < RECENT_MTIME_SECONDS
53
- end
54
-
55
- def files
56
- pi = Space::Core::Paths.content_tree(@pi_root).select { |p| p.end_with?(".jsonl") }
57
- .map { |p| [p, SessionId.for_pi(p)] }
58
- claude = Space::Core::Paths.content_tree(@claude_root).select { |p| p.end_with?(".jsonl") }
59
- .map { |p| [p, SessionId.for_claude(p)] }
60
- pi + claude
61
- end
62
- end
63
- end
64
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Space::Architect
4
- module SessionSync
5
- # Frozen session-id derivation rules for the two store shapes:
6
- # pi: <mangled-cwd>/<timestamp>_<sessionId>.jsonl -> part after the last "_"
7
- # claude: <mangled-cwd>/<sessionId>.jsonl -> basename minus ".jsonl"
8
- module SessionId
9
- def self.for_pi(path)
10
- base = File.basename(path, ".jsonl")
11
- idx = base.rindex("_")
12
- idx ? base[(idx + 1)..] : base
13
- end
14
-
15
- def self.for_claude(path)
16
- File.basename(path, ".jsonl")
17
- end
18
- end
19
- end
20
- end
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "session_sync/session_id"
4
- require_relative "session_sync/cursor"
5
- require_relative "session_sync/runner"
6
- require_relative "session_sync/plist"
7
- require_relative "session_sync/bin_path"
8
-
9
- module Space::Architect
10
- # Session-sync rail's laptop half: scans ~/.pi/agent/sessions/**/*.jsonl and
11
- # ~/.claude/projects/**/*.jsonl for conversation files, uploads new/grown
12
- # ones to a space-server via ConversationsClient, and installs a per-user
13
- # launchd agent that runs the sync on an interval. Opencode (sqlite) is out
14
- # of scope for this rail.
15
- module SessionSync
16
- LABEL = "io.github.jetpks.space-architect.session-sync"
17
- APP_NAME = "space-architect"
18
- TOKEN_ENV = "SPACE_ARCHITECT_INGEST_TOKEN"
19
-
20
- module_function
21
-
22
- # The one op-read seam under lib/: resolves an op:// ref via `op read`.
23
- # Shared by ConversationsClient's default op_resolver and
24
- # Sessions::Agent::Install (both test-injectable around this call).
25
- def resolve_token(ref)
26
- value = IO.popen(["op", "read", ref], &:read)
27
- raise Space::Core::Error, "op read failed for #{ref}" unless $?.success?
28
-
29
- value.chomp
30
- end
31
-
32
- def state_dir(env: ENV)
33
- File.join(Space::Core::XDG.state_home(env: env).to_s, APP_NAME)
34
- end
35
-
36
- def default_state_file(env: ENV)
37
- File.join(state_dir(env: env), "session-sync.yaml")
38
- end
39
-
40
- def default_pi_root(env: ENV)
41
- File.join(Space::Core::XDG.home(env: env), ".pi", "agent", "sessions")
42
- end
43
-
44
- def default_claude_root(env: ENV)
45
- File.join(Space::Core::XDG.home(env: env), ".claude", "projects")
46
- end
47
-
48
- def log_dir(env: ENV)
49
- File.join(state_dir(env: env), "logs")
50
- end
51
-
52
- def launch_agents_dir(env: ENV)
53
- File.join(Space::Core::XDG.home(env: env), "Library", "LaunchAgents")
54
- end
55
- end
56
- end