Repository navigation
Expand file tree
/
Copy pathopencode_server.lua
More file actions
368 lines (329 loc) · 10.8 KB
/
Copy pathopencode_server.lua
File metadata and controls
368 lines (329 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
local util = require('opencode.util')
local safe_call = util.safe_call
local Promise = require('opencode.promise')
local auth = require('opencode.auth')
local protocol_connection = require('opencode.protocols.connection')
--- @class OpencodeServer
---@field job vim.SystemObj|nil The vim.system job handle
---@field url string|nil The server URL once ready
---@field port number|nil The port this server is using (for custom servers)
---@field handle integer|nil Compatibility property for job.stop interface
---@field protocol? 'v1'|'v2' Protocol selected by authenticated health probe
---@field version? string Server version returned by the selected health endpoint
---@field server_identity? {version: string, pid: integer|nil} Identity facts returned by the probe or acquisition
---@field credential? {username: string, password?: string} Credential owned by this connection
---@field operations? OpencodeV1Operations|OpencodeV2Operations Protocol operations selected when the connection becomes ready
---@field observations table<string, OpencodeObservation?> Observations owned by this connection
---@field shutdown_promise Promise<boolean>
---@field private _ready boolean
---@field private _shutdown_requested boolean
---@field private _release_process? fun()
---@field private _stream? {shutdown: fun(self: table)}
---@field private _requests table<table, true>
---@field private _observe? fun(connection: OpencodeServer, ref: {id: string, location?: OpencodeLocation}): OpencodeObservation
---@field private _close_observations? fun(connection: OpencodeServer)
local OpencodeServer = {}
OpencodeServer.__index = OpencodeServer
local vim_leave_setup = false
local function ensure_vim_leave_autocmd()
if vim_leave_setup then
return
end
vim_leave_setup = true
vim.api.nvim_create_autocmd('VimLeavePre', {
group = vim.api.nvim_create_augroup('OpencodeVimLeavePre', { clear = true }),
callback = function()
local state = require('opencode.state')
if state.opencode_server then
state.opencode_server:close()
end
end,
})
end
--- Create a new ServerJob instance
--- @return OpencodeServer
function OpencodeServer.new()
ensure_vim_leave_autocmd()
return setmetatable({
job = nil,
url = nil,
port = nil,
handle = nil,
protocol = nil,
version = nil,
server_identity = nil,
credential = nil,
operations = nil,
observations = {},
shutdown_promise = Promise.new(),
_ready = false,
_shutdown_requested = false,
_release_process = nil,
_stream = nil,
_requests = {},
_observe = nil,
_close_observations = nil,
}, OpencodeServer)
end
--- Create a server instance that connects to a custom server
--- @param url string The custom server URL
--- @param port number|nil The port number (for PID tracking)
--- @return OpencodeServer
function OpencodeServer.from_custom(url, port)
local instance = OpencodeServer.new()
instance.url = url
instance.port = port
return instance
end
---@return boolean
function OpencodeServer:is_ready()
return self._ready
end
---@param release? fun()
function OpencodeServer:set_process_release(release)
if self._ready or self.shutdown_promise:is_resolved() then
error('cannot change release behavior of a ready connection')
end
self._release_process = release
end
---@return boolean
function OpencodeServer:can_release_process()
return self._release_process ~= nil
end
---@return boolean
function OpencodeServer:release_process()
local release = self._release_process
self._release_process = nil
if not release then
return false
end
release()
return true
end
---@param stream? {shutdown: fun(self: table)}
function OpencodeServer:set_stream(stream)
if stream and self._stream then
pcall(stream.shutdown, stream)
error('Connection already owns an SSE stream')
end
if stream and not self:is_ready() then
pcall(stream.shutdown, stream)
error('cannot attach SSE to a closed Connection')
end
self._stream = stream
end
---@param request {shutdown: fun(self: table)}
function OpencodeServer:_track_request(request)
if not self:is_ready() then
pcall(request.shutdown, request)
error('cannot attach HTTP request to a closed Connection')
end
self._requests[request] = true
end
---@param request table
function OpencodeServer:_untrack_request(request)
self._requests[request] = nil
end
--- Publish the connection only after its authenticated protocol probe succeeds.
---@return OpencodeServer
function OpencodeServer:mark_ready()
if self._ready then
return self
end
if self.shutdown_promise:is_resolved() then
error('cannot ready a closed Connection')
end
if type(self.url) ~= 'string' or self.url == '' then
error('ready connection requires url')
end
local runtime = self.protocol and protocol_connection.runtime(self.protocol)
if not runtime then
error('ready connection requires protocol')
end
if
type(self.server_identity) ~= 'table'
or type(self.server_identity.version) ~= 'string'
or self.server_identity.version == ''
then
error('ready connection requires server_identity')
end
if self.server_identity.pid ~= nil and type(self.server_identity.pid) ~= 'number' then
error('ready connection server_identity pid must be a number')
end
self.version = self.server_identity.version
if type(self.credential) ~= 'table' or type(self.credential.username) ~= 'string' then
error('ready connection requires credential')
end
if self.credential.password ~= nil and type(self.credential.password) ~= 'string' then
error('ready connection credential password must be a string')
end
self.operations = runtime.operations
local observation_protocol = runtime.observation
self._observe = observation_protocol.new
self._close_observations = observation_protocol.close
self._ready = true
return self
end
---Return the unique Observation for a session on this Connection.
---@param ref {id: string, location?: OpencodeLocation}
---@return OpencodeObservation
function OpencodeServer:observe(ref)
if not self:is_ready() or not self._observe then
error('cannot observe a session on a closed Connection')
end
if type(ref) ~= 'table' or type(ref.id) ~= 'string' or ref.id == '' then
error('observe requires a session id')
end
local existing = self.observations[ref.id]
if existing then
return existing
end
local observation = self._observe(self, ref)
self.observations[ref.id] = observation
return observation
end
--- Probe protocol using authenticated health endpoints.
---@param timeout_ms number|nil
---@return Promise<{protocol: 'v1'|'v2', response: table}>
function OpencodeServer:probe_connection(timeout_ms)
return protocol_connection.probe(self, timeout_ms)
end
---Check if the server is reachable via its health endpoint.
---@return Promise<boolean>
function OpencodeServer:check_health()
if not self._ready or not self.url then
return Promise.new():resolve(false)
end
return protocol_connection.check_health(self)
end
---@return Promise<boolean>
function OpencodeServer:close()
if self.shutdown_promise:is_resolved() then
return self.shutdown_promise
end
self._shutdown_requested = true
self._ready = false
local close_observations = self._close_observations
self._close_observations = nil
if close_observations then
close_observations(self)
end
local requests = self._requests
self._requests = {}
for request in pairs(requests) do
pcall(request.shutdown, request)
end
local stream = self._stream
self._stream = nil
if stream then
pcall(stream.shutdown, stream)
end
local released = false
if self.port then
released = require('opencode.port_mapping').unregister(self.port, self)
end
if not released then
self:release_process()
end
self.job = nil
self.handle = nil
self.custom_pid = nil
self.shutdown_promise:resolve(true)
return self.shutdown_promise
end
---@return Promise<boolean>
function OpencodeServer:shutdown()
return self:close()
end
--- @class OpencodeServerSpawnOpts
--- @field cwd? string
--- @field command string[]
--- @field auto_kill? boolean
--- @field listening_url fun(output: string): string|nil
--- @field on_ready fun(job: any, url: string)
--- @field on_error fun(err: any)
--- @field on_exit fun(exit_opts: vim.SystemCompleted )
--- Spawn the opencode server for this ServerJob instance.
--- @param opts OpencodeServerSpawnOpts
function OpencodeServer:spawn(opts)
opts = opts or {}
local log = require('opencode.log')
self._shutdown_requested = false
local listening = false
local startup_failed = false
local startup_stderr = {}
if type(opts.command) ~= 'table' or #opts.command == 0 then
error('spawn requires a command')
end
if type(opts.listening_url) ~= 'function' then
error('spawn requires a listening URL parser')
end
local cmd = opts.command
log.debug('spawn: starting opencode server with command: %s', vim.inspect(cmd))
local function fail_startup(err)
if self._ready or startup_failed or self._shutdown_requested then
return
end
startup_failed = true
safe_call(opts.on_error, err)
end
if opts.auto_kill ~= false then
self:set_process_release(function()
if self.job and self.job.pid then
require('opencode.util').kill_pid(self.job.pid)
end
end)
end
self.job = vim.system(cmd, {
cwd = opts.cwd,
env = auth.get_env(self.credential),
stdout = function(err, data)
if err then
fail_startup(err)
return
end
if data then
local url = opts.listening_url(data)
if url and not listening then
listening = true
self.url = url
safe_call(opts.on_ready, self.job, url)
log.debug('spawn: server listening at url=%s', url)
end
end
end,
stderr = function(err, data)
if err then
fail_startup(err)
return
end
if data and data ~= '' then
table.insert(startup_stderr, data)
log.debug('spawn: stderr output: %s', vim.inspect(data))
end
end,
}, function(exit_opts)
if not self._ready and not startup_failed and not self._shutdown_requested then
local stderr_output = table.concat(startup_stderr)
local startup_error = stderr_output ~= '' and stderr_output
or string.format(
'Opencode server exited before reporting ready state (code=%s, signal=%s)',
tostring(exit_opts and exit_opts.code),
tostring(exit_opts and exit_opts.signal)
)
fail_startup(startup_error)
end
self._release_process = nil
self.job = nil
self.handle = nil
safe_call(opts.on_exit, exit_opts)
self:close()
end)
self.handle = self.job and self.job.pid
log.debug('spawn: started job with pid=%s', tostring(self.job and self.job.pid))
end
---@return Promise<boolean>
function OpencodeServer:get_shutdown_promise()
return self.shutdown_promise
end
return OpencodeServer