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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +2 -4
- data/lib/space_architect/architect_project.rb +8 -21
- 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
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: space-architect
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 9.
|
|
4
|
+
version: 9.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Jacobs
|
|
@@ -23,20 +23,6 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '9.1'
|
|
26
|
-
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: async-http
|
|
28
|
-
requirement: !ruby/object:Gem::Requirement
|
|
29
|
-
requirements:
|
|
30
|
-
- - "~>"
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '0.95'
|
|
33
|
-
type: :runtime
|
|
34
|
-
prerelease: false
|
|
35
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
-
requirements:
|
|
37
|
-
- - "~>"
|
|
38
|
-
- !ruby/object:Gem::Version
|
|
39
|
-
version: '0.95'
|
|
40
26
|
- !ruby/object:Gem::Dependency
|
|
41
27
|
name: async-process
|
|
42
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -51,20 +37,6 @@ dependencies:
|
|
|
51
37
|
- - "~>"
|
|
52
38
|
- !ruby/object:Gem::Version
|
|
53
39
|
version: '1.4'
|
|
54
|
-
- !ruby/object:Gem::Dependency
|
|
55
|
-
name: protocol-http
|
|
56
|
-
requirement: !ruby/object:Gem::Requirement
|
|
57
|
-
requirements:
|
|
58
|
-
- - "~>"
|
|
59
|
-
- !ruby/object:Gem::Version
|
|
60
|
-
version: '0.62'
|
|
61
|
-
type: :runtime
|
|
62
|
-
prerelease: false
|
|
63
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
-
requirements:
|
|
65
|
-
- - "~>"
|
|
66
|
-
- !ruby/object:Gem::Version
|
|
67
|
-
version: '0.62'
|
|
68
40
|
- !ruby/object:Gem::Dependency
|
|
69
41
|
name: pastel
|
|
70
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -166,8 +138,7 @@ dependencies:
|
|
|
166
138
|
description: 'A dry-cli CLI for the Architect Loop: iterations, briefs, freezes, verdicts,
|
|
167
139
|
lane worktrees, and headless pi dispatch — the judgment loop that runs inside space-cadet''s
|
|
168
140
|
task-scoped workspaces. Depends on the space-cadet gem for the spaces substrate
|
|
169
|
-
(hard)
|
|
170
|
-
at will).'
|
|
141
|
+
(hard).'
|
|
171
142
|
email:
|
|
172
143
|
- eric@ebj.dev
|
|
173
144
|
executables:
|
|
@@ -185,12 +156,10 @@ files:
|
|
|
185
156
|
- lib/space_architect/cli.rb
|
|
186
157
|
- lib/space_architect/cli/architect.rb
|
|
187
158
|
- lib/space_architect/cli/research.rb
|
|
188
|
-
- lib/space_architect/conversations_client.rb
|
|
189
159
|
- lib/space_architect/extension_installer.rb
|
|
190
160
|
- lib/space_architect/gate_evaluator.rb
|
|
191
161
|
- lib/space_architect/gate_lint.rb
|
|
192
162
|
- lib/space_architect/harness.rb
|
|
193
|
-
- lib/space_architect/jobs_client.rb
|
|
194
163
|
- lib/space_architect/loop_cost.rb
|
|
195
164
|
- lib/space_architect/looplog.rb
|
|
196
165
|
- lib/space_architect/pi/builder-guard.ts
|
|
@@ -202,16 +171,8 @@ files:
|
|
|
202
171
|
- lib/space_architect/research/run.rb
|
|
203
172
|
- lib/space_architect/research/supervisor.rb
|
|
204
173
|
- lib/space_architect/routing.rb
|
|
205
|
-
- lib/space_architect/run_creator.rb
|
|
206
174
|
- lib/space_architect/runlog.rb
|
|
207
|
-
- lib/space_architect/session_sync.rb
|
|
208
|
-
- lib/space_architect/session_sync/bin_path.rb
|
|
209
|
-
- lib/space_architect/session_sync/cursor.rb
|
|
210
|
-
- lib/space_architect/session_sync/plist.rb
|
|
211
|
-
- lib/space_architect/session_sync/runner.rb
|
|
212
|
-
- lib/space_architect/session_sync/session_id.rb
|
|
213
175
|
- lib/space_architect/skill_installer.rb
|
|
214
|
-
- lib/space_architect/space_push.rb
|
|
215
176
|
- lib/space_architect/templates/architect.md.erb
|
|
216
177
|
- lib/space_architect/templates/brief.md.erb
|
|
217
178
|
- lib/space_architect/templates/iteration.md.erb
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "async/http/client"
|
|
4
|
-
require "async/http/endpoint"
|
|
5
|
-
require "json"
|
|
6
|
-
require "securerandom"
|
|
7
|
-
|
|
8
|
-
module Space::Architect
|
|
9
|
-
# HTTP client for the space-server conversations upload API, modeled on
|
|
10
|
-
# JobsClient: Bearer auth, injectable async-http client. Unlike JobsClient,
|
|
11
|
-
# a non-2xx response is returned to the caller (not raised) — SessionSync
|
|
12
|
-
# needs to distinguish created/updated/failed per file without aborting
|
|
13
|
-
# the whole run on one bad upload.
|
|
14
|
-
class ConversationsClient
|
|
15
|
-
def initialize(host, token, client: nil, op_resolver: nil)
|
|
16
|
-
@host = host.chomp("/")
|
|
17
|
-
@token = token
|
|
18
|
-
@client = client
|
|
19
|
-
@op_resolver = op_resolver || SessionSync.method(:resolve_token)
|
|
20
|
-
@resolved_token = nil
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# POST /conversations — multipart per the frozen wire contract:
|
|
24
|
-
# conversation[source_file] (the file), conversation[session_id].
|
|
25
|
-
# Returns {status:, conversation_id:, action:, errors:}.
|
|
26
|
-
def upload(path:, session_id:)
|
|
27
|
-
Sync do
|
|
28
|
-
with_client do |c|
|
|
29
|
-
boundary = "ArchitectBoundary#{SecureRandom.hex(8)}"
|
|
30
|
-
body = build_multipart(boundary, path, session_id)
|
|
31
|
-
response = c.post("/conversations", headers: headers(boundary), body: body)
|
|
32
|
-
status = response.status
|
|
33
|
-
parsed = begin
|
|
34
|
-
JSON.parse(response.read || "{}")
|
|
35
|
-
rescue JSON::ParserError
|
|
36
|
-
{}
|
|
37
|
-
end
|
|
38
|
-
{status: status, conversation_id: parsed["conversation_id"], action: parsed["action"], errors: parsed["errors"]}
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
private
|
|
44
|
-
|
|
45
|
-
def with_client
|
|
46
|
-
if @client
|
|
47
|
-
yield @client
|
|
48
|
-
else
|
|
49
|
-
Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) { |c| yield c }
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def headers(boundary)
|
|
54
|
-
[
|
|
55
|
-
["authorization", "Bearer #{resolved_token}"],
|
|
56
|
-
["content-type", "multipart/form-data; boundary=#{boundary}"]
|
|
57
|
-
]
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Resolved once per client instance (per run), per spec.
|
|
61
|
-
def resolved_token
|
|
62
|
-
@resolved_token ||= @token.start_with?("op://") ? @op_resolver.call(@token) : @token
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def build_multipart(boundary, path, session_id)
|
|
66
|
-
crlf = "\r\n"
|
|
67
|
-
filename = File.basename(path)
|
|
68
|
-
file_content = File.binread(path)
|
|
69
|
-
+"--#{boundary}#{crlf}" \
|
|
70
|
-
"Content-Disposition: form-data; name=\"conversation[source_file]\"; filename=\"#{filename}\"#{crlf}" \
|
|
71
|
-
"Content-Type: application/octet-stream#{crlf}" \
|
|
72
|
-
"#{crlf}" \
|
|
73
|
-
"#{file_content}#{crlf}" \
|
|
74
|
-
"--#{boundary}#{crlf}" \
|
|
75
|
-
"Content-Disposition: form-data; name=\"conversation[session_id]\"#{crlf}" \
|
|
76
|
-
"#{crlf}" \
|
|
77
|
-
"#{session_id}#{crlf}" \
|
|
78
|
-
"--#{boundary}--#{crlf}"
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
|
@@ -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("&", "&")
|
|
76
|
-
.gsub("<", "<")
|
|
77
|
-
.gsub(">", ">")
|
|
78
|
-
.gsub("\"", """)
|
|
79
|
-
.gsub("'", "'")
|
|
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
|