space-architect 9.2.1 → 9.3.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 +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +2 -4
- data/lib/space_architect/architect_project.rb +2 -20
- data/lib/space_architect/cli/architect.rb +10 -294
- data/lib/space_architect/harness.rb +2 -51
- data/lib/space_architect/version.rb +1 -1
- data/lib/space_architect.rb +0 -4
- metadata +2 -41
- data/lib/space_architect/conversations_client.rb +0 -81
- data/lib/space_architect/jobs_client.rb +0 -147
- data/lib/space_architect/run_creator.rb +0 -53
- data/lib/space_architect/session_sync/bin_path.rb +0 -28
- data/lib/space_architect/session_sync/cursor.rb +0 -43
- data/lib/space_architect/session_sync/plist.rb +0 -111
- data/lib/space_architect/session_sync/runner.rb +0 -64
- data/lib/space_architect/session_sync/session_id.rb +0 -20
- data/lib/space_architect/session_sync.rb +0 -56
- data/lib/space_architect/space_push.rb +0 -274
|
@@ -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
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "zlib"
|
|
4
|
-
require "tempfile"
|
|
5
|
-
require "json"
|
|
6
|
-
require "async/http/client"
|
|
7
|
-
require "async/http/endpoint"
|
|
8
|
-
require "protocol/http/body/readable"
|
|
9
|
-
|
|
10
|
-
module Space::Architect
|
|
11
|
-
# Gem-side sender for the space-server import mouth: packs the current space
|
|
12
|
-
# into a gzipped tar and POSTs it to <host>/spaces/import
|
|
13
|
-
# (Content-Type: application/gzip, Bearer auth). A 200 response is the
|
|
14
|
-
# server's import summary {id:, slug:, iterations:, artifacts:, runs:};
|
|
15
|
-
# any non-200 raises Space::Core::Error carrying the server's error body.
|
|
16
|
-
class SpacePush
|
|
17
|
-
IMPORT_PATH = "/spaces/import"
|
|
18
|
-
CONTENT_TYPE = "application/gzip"
|
|
19
|
-
|
|
20
|
-
def initialize(host, token, client: nil)
|
|
21
|
-
@host = host.chomp("/")
|
|
22
|
-
@token = token
|
|
23
|
-
@client = client
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# POSTs a packed bundle (a gzip tar path) per the frozen import contract
|
|
27
|
-
# and returns the parsed summary hash. Raises Space::Core::Error on any
|
|
28
|
-
# non-200 — the CLI's handle_errors turns that into a stderr message and
|
|
29
|
-
# a nonzero exit.
|
|
30
|
-
def push(bundle_path)
|
|
31
|
-
Sync do
|
|
32
|
-
with_client do |c|
|
|
33
|
-
body = file_body(bundle_path)
|
|
34
|
-
begin
|
|
35
|
-
response = c.post(IMPORT_PATH, headers: headers, body: body)
|
|
36
|
-
parse_response(response)
|
|
37
|
-
ensure
|
|
38
|
-
body.close
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, SocketError => e
|
|
43
|
-
raise Space::Core::Error, "POST #{IMPORT_PATH} failed: #{e.message} (host: #{@host})"
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def with_client
|
|
49
|
-
if @client
|
|
50
|
-
yield @client
|
|
51
|
-
else
|
|
52
|
-
Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) { |c| yield c }
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def headers
|
|
57
|
-
[
|
|
58
|
-
["authorization", "Bearer #{@token}"],
|
|
59
|
-
["content-type", CONTENT_TYPE]
|
|
60
|
-
]
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Streams the bundle off disk — the bundle is never slurped into memory.
|
|
64
|
-
# StreamBody's length drives the content-length on the wire (HTTP/1) and
|
|
65
|
-
# the HTTP/2 pseudo-header, so it must not also be set as a header.
|
|
66
|
-
def file_body(bundle_path)
|
|
67
|
-
StreamBody.new(File.open(bundle_path, "rb"), File.size(bundle_path))
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def parse_response(response)
|
|
71
|
-
status = response.status
|
|
72
|
-
body = response.read || ""
|
|
73
|
-
raise Space::Core::Error, "POST #{IMPORT_PATH} failed (#{status}): #{body[0, 200]}" unless status == 200
|
|
74
|
-
|
|
75
|
-
JSON.parse(body)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# A Protocol::HTTP body that lazily reads chunks off an IO, so the HTTP
|
|
79
|
-
# client streams the request body while the bundle stays on disk.
|
|
80
|
-
class StreamBody < Protocol::HTTP::Body::Readable
|
|
81
|
-
CHUNK_SIZE = 64 * 1024
|
|
82
|
-
|
|
83
|
-
def initialize(io, length)
|
|
84
|
-
@io = io
|
|
85
|
-
@length = length
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
attr :length
|
|
89
|
-
|
|
90
|
-
def read
|
|
91
|
-
@io.read(CHUNK_SIZE)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def close(error = nil)
|
|
95
|
-
@io.close unless @io.closed?
|
|
96
|
-
super
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# Packs a space into a gzipped tar on disk, streaming — neither the tar
|
|
101
|
-
# nor the space's files are held in memory.
|
|
102
|
-
#
|
|
103
|
-
# Bundle cut (correctness-critical): EXCLUDE repos/, the lanes' worktrees
|
|
104
|
-
# at build/<id>-<lane>/wt, tmp/, node_modules/ anywhere, .pi/, and *.log
|
|
105
|
-
# files. INCLUDE everything else — the importer's read set is space.yaml,
|
|
106
|
-
# .git/ (history carries iteration timestamps), architecture/, notes/,
|
|
107
|
-
# and build/<id>-<lane>/{prompt,report}.md + run.jsonl.
|
|
108
|
-
class Packer
|
|
109
|
-
BLOCK_SIZE = 512
|
|
110
|
-
PRUNED_DIRS = %w[repos tmp .pi node_modules]
|
|
111
|
-
WORKTREE_MATCH = %r{\Abuild/[^/]+/wt\z}
|
|
112
|
-
|
|
113
|
-
Entry = Struct.new(:name, :path, :type)
|
|
114
|
-
|
|
115
|
-
def initialize(root)
|
|
116
|
-
@root = root.to_s
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Streams the space into a fresh tmpfile and returns its path. The
|
|
120
|
-
# caller owns the tmpfile (unlink it when done).
|
|
121
|
-
def pack
|
|
122
|
-
tmp = Tempfile.create(["architect-push", ".tar.gz"])
|
|
123
|
-
tmp.binmode
|
|
124
|
-
Zlib::GzipWriter.wrap(tmp) do |gz|
|
|
125
|
-
writer = TarWriter.new(gz)
|
|
126
|
-
walk { |entry| add(writer, entry) }
|
|
127
|
-
writer.finish
|
|
128
|
-
end
|
|
129
|
-
tmp.close
|
|
130
|
-
tmp.path
|
|
131
|
-
rescue
|
|
132
|
-
tmp.close
|
|
133
|
-
File.unlink(tmp.path) rescue nil
|
|
134
|
-
raise
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
private
|
|
138
|
-
|
|
139
|
-
def add(writer, entry)
|
|
140
|
-
if entry.type == :dir
|
|
141
|
-
writer.write_dir(entry.name, mtime: File.mtime(entry.path))
|
|
142
|
-
else
|
|
143
|
-
writer.write_file(entry.path, entry.name,
|
|
144
|
-
mode: File.stat(entry.path).mode & 0o7777, mtime: File.mtime(entry.path))
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def walk(&)
|
|
149
|
-
walk_dir("", &)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def walk_dir(rel, &)
|
|
153
|
-
dir = rel.empty? ? @root : File.join(@root, rel)
|
|
154
|
-
Dir.children(dir).sort.each do |name| # paths:exempt — the dotfile-inclusive walk is deliberate (.git must ship); pruned subtrees (repos/, worktrees) are skipped before descending, so their bulk is never enumerated and the walk streams
|
|
155
|
-
child = rel.empty? ? name : "#{rel}/#{name}"
|
|
156
|
-
abs = File.join(@root, child)
|
|
157
|
-
stat = File.lstat(abs)
|
|
158
|
-
|
|
159
|
-
if stat.directory?
|
|
160
|
-
next if pruned?(child)
|
|
161
|
-
|
|
162
|
-
yield Entry.new("#{child}/", abs, :dir)
|
|
163
|
-
walk_dir(child, &)
|
|
164
|
-
elsif stat.file?
|
|
165
|
-
next if child.end_with?(".log")
|
|
166
|
-
|
|
167
|
-
yield Entry.new(child, abs, :file)
|
|
168
|
-
end
|
|
169
|
-
# Symlinks and other oddities are skipped: the bundle carries regular
|
|
170
|
-
# files and directories only, so it stays traversal-proof.
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def pruned?(rel)
|
|
175
|
-
PRUNED_DIRS.include?(File.basename(rel)) || WORKTREE_MATCH.match?(rel)
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# Minimal ustar writer — stdlib only, the Gemfile carries no tar gem.
|
|
179
|
-
# Regular files and directories; file contents stream 512-byte-aligned.
|
|
180
|
-
# Names longer than 100 bytes split across the ustar prefix field.
|
|
181
|
-
class TarWriter
|
|
182
|
-
BLOCK_SIZE = 512
|
|
183
|
-
TYPE_FILE = "0"
|
|
184
|
-
TYPE_DIR = "5"
|
|
185
|
-
|
|
186
|
-
def initialize(io)
|
|
187
|
-
@io = io
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def write_dir(name, mtime:)
|
|
191
|
-
write_header(name, mode: 0o755, mtime: mtime, size: 0, type: TYPE_DIR)
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def write_file(path, name, mode:, mtime:)
|
|
195
|
-
size = File.size(path)
|
|
196
|
-
write_header(name, mode: mode, mtime: mtime, size: size, type: TYPE_FILE)
|
|
197
|
-
IO.copy_stream(path, @io)
|
|
198
|
-
pad = (BLOCK_SIZE - (size % BLOCK_SIZE)) % BLOCK_SIZE
|
|
199
|
-
@io.write("\0" * pad) unless pad.zero?
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
# Two zero blocks terminate the archive.
|
|
203
|
-
def finish
|
|
204
|
-
@io.write("\0" * (BLOCK_SIZE * 2))
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
private
|
|
208
|
-
|
|
209
|
-
def write_header(name, mode:, mtime:, size:, type:)
|
|
210
|
-
name = name.b
|
|
211
|
-
split = split_name(name)
|
|
212
|
-
if split
|
|
213
|
-
base, prefix = split
|
|
214
|
-
else
|
|
215
|
-
# Not representable via the ustar prefix field — fall back to a
|
|
216
|
-
# GNU 'L' long-name entry, which every mainstream reader honors.
|
|
217
|
-
write_long_name(name)
|
|
218
|
-
base, prefix = name[0, 100], nil
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
header = String.new(encoding: Encoding::BINARY)
|
|
222
|
-
header << base.ljust(100, "\0")
|
|
223
|
-
header << octal(mode, 8)
|
|
224
|
-
header << octal(0, 8) # uid — the bundle is anonymous
|
|
225
|
-
header << octal(0, 8) # gid
|
|
226
|
-
header << octal(size, 12)
|
|
227
|
-
header << octal(mtime.to_i, 12)
|
|
228
|
-
header << " " * 8 # checksum placeholder
|
|
229
|
-
header << type
|
|
230
|
-
header << "\0" * 100 # linkname
|
|
231
|
-
header << "ustar\0" # magic
|
|
232
|
-
header << "00" # version
|
|
233
|
-
header << "\0" * 32 # uname
|
|
234
|
-
header << "\0" * 32 # gname
|
|
235
|
-
header << "\0" * 8 # devmajor
|
|
236
|
-
header << "\0" * 8 # devminor
|
|
237
|
-
header << (prefix || "").ljust(155, "\0")
|
|
238
|
-
header << "\0" * (BLOCK_SIZE - header.bytesize)
|
|
239
|
-
raise "tar header overflow: #{name}" if header.bytesize != BLOCK_SIZE
|
|
240
|
-
|
|
241
|
-
header[148, 8] = format("%06o\0 ", header.sum)
|
|
242
|
-
@io.write(header)
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
# GNU 'L' long-name entry: the full name NUL-terminated as the entry
|
|
246
|
-
# body, then the real header carrying the name's first 100 bytes.
|
|
247
|
-
def write_long_name(name)
|
|
248
|
-
content = "#{name}\0"
|
|
249
|
-
write_header("././@LongLink", mode: 0o644, mtime: Time.now, size: content.bytesize, type: "L")
|
|
250
|
-
@io.write(content)
|
|
251
|
-
pad = (BLOCK_SIZE - (content.bytesize % BLOCK_SIZE)) % BLOCK_SIZE
|
|
252
|
-
@io.write("\0" * pad) unless pad.zero?
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# [base, prefix] split across the ustar name (100) and prefix (155)
|
|
256
|
-
# fields at a "/" boundary, or nil when the name cannot be split and
|
|
257
|
-
# needs a GNU long-name entry.
|
|
258
|
-
def split_name(name)
|
|
259
|
-
return [name, nil] if name.bytesize <= 100
|
|
260
|
-
|
|
261
|
-
min_cut = [name.bytesize - 101, 0].max # the name field must fit in 100 bytes
|
|
262
|
-
cut = name.index("/", min_cut)
|
|
263
|
-
return nil if cut.nil? || cut > 155
|
|
264
|
-
|
|
265
|
-
[name[(cut + 1)..], name[0, cut]]
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
def octal(value, width)
|
|
269
|
-
format("%0#{width - 1}o\0", value)
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
|
-
end
|