space-architect 9.2.0 → 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.
@@ -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