@roj-ai/sdk 0.1.26 → 0.1.28
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.
- package/dist/bootstrap.d.ts +2 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/plugins/resources/index.d.ts +1 -1
- package/dist/plugins/resources/index.d.ts.map +1 -1
- package/dist/plugins/resources/plugin.d.ts +7 -1
- package/dist/plugins/resources/plugin.d.ts.map +1 -1
- package/dist/plugins/resources/plugin.js +25 -5
- package/dist/plugins/resources/plugin.js.map +1 -1
- package/dist/plugins/resources/resources.integration.test.d.ts +2 -0
- package/dist/plugins/resources/resources.integration.test.d.ts.map +1 -0
- package/dist/plugins/resources/resources.integration.test.js +62 -0
- package/dist/plugins/resources/resources.integration.test.js.map +1 -0
- package/dist/plugins/resources/state.d.ts +2 -0
- package/dist/plugins/resources/state.d.ts.map +1 -1
- package/dist/plugins/resources/state.js +1 -0
- package/dist/plugins/resources/state.js.map +1 -1
- package/dist/plugins/services/plugin.d.ts +4 -0
- package/dist/plugins/services/plugin.d.ts.map +1 -1
- package/dist/plugins/services/plugin.js +34 -9
- package/dist/plugins/services/plugin.js.map +1 -1
- package/dist/plugins/services/prompt.d.ts.map +1 -1
- package/dist/plugins/services/prompt.js +3 -0
- package/dist/plugins/services/prompt.js.map +1 -1
- package/dist/plugins/services/schema.d.ts +38 -4
- package/dist/plugins/services/schema.d.ts.map +1 -1
- package/dist/plugins/services/service.d.ts +11 -1
- package/dist/plugins/services/service.d.ts.map +1 -1
- package/dist/plugins/services/service.js +182 -46
- package/dist/plugins/services/service.js.map +1 -1
- package/dist/plugins/services/services.integration.test.js +128 -3
- package/dist/plugins/services/services.integration.test.js.map +1 -1
- package/dist/testing/preset-helpers.d.ts +1 -0
- package/dist/testing/preset-helpers.d.ts.map +1 -1
- package/dist/testing/preset-helpers.js +1 -0
- package/dist/testing/preset-helpers.js.map +1 -1
- package/dist/testing/test-harness.d.ts +9 -2
- package/dist/testing/test-harness.d.ts.map +1 -1
- package/dist/testing/test-harness.js +31 -3
- package/dist/testing/test-harness.js.map +1 -1
- package/dist/testing/test-harness.test.js +41 -0
- package/dist/testing/test-harness.test.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +10 -2
- package/src/plugins/resources/index.ts +1 -1
- package/src/plugins/resources/plugin.ts +35 -6
- package/src/plugins/resources/resources.integration.test.ts +68 -0
- package/src/plugins/resources/state.ts +2 -0
- package/src/plugins/services/plugin.ts +32 -9
- package/src/plugins/services/prompt.ts +3 -0
- package/src/plugins/services/schema.ts +40 -4
- package/src/plugins/services/service.ts +202 -52
- package/src/plugins/services/services.integration.test.ts +143 -4
- package/src/testing/preset-helpers.ts +2 -0
- package/src/testing/test-harness.test.ts +51 -0
- package/src/testing/test-harness.ts +37 -4
|
@@ -13,7 +13,7 @@ import { Err, Ok } from '~/lib/utils/result.js'
|
|
|
13
13
|
import type { FileSystem } from '~/platform/fs.js'
|
|
14
14
|
import type { ChildProcess, ProcessRunner } from '~/platform/process.js'
|
|
15
15
|
import type { PortPool } from '~/plugins/services/port-pool.js'
|
|
16
|
-
import type { ServiceConfig, ServiceStatus } from '~/plugins/services/schema.js'
|
|
16
|
+
import type { ServiceConfig, ServiceStartArgs, ServiceStatus } from '~/plugins/services/schema.js'
|
|
17
17
|
import type { ToolError } from '../../core/tools/executor.js'
|
|
18
18
|
import type { Logger } from '../../lib/logger/logger.js'
|
|
19
19
|
import { RingBuffer } from '../../lib/logger/ring-buffer.js'
|
|
@@ -57,9 +57,20 @@ interface RunningService {
|
|
|
57
57
|
pid: number
|
|
58
58
|
status: ServiceStatus
|
|
59
59
|
port: number
|
|
60
|
+
cwd?: string
|
|
61
|
+
command: string
|
|
60
62
|
logs: RingBuffer
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
export interface ServiceStatusChangeDetails {
|
|
66
|
+
port?: number
|
|
67
|
+
error?: string
|
|
68
|
+
pid?: number
|
|
69
|
+
pidStartTime?: number
|
|
70
|
+
cwd?: string
|
|
71
|
+
command?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
/** Matches a port bind-conflict across the Node and Bun runtimes. */
|
|
64
75
|
const PORT_CONFLICT_PATTERN = /EADDRINUSE|address already in use/i
|
|
65
76
|
|
|
@@ -98,10 +109,7 @@ export class ServiceExecutor {
|
|
|
98
109
|
sessionId: string,
|
|
99
110
|
serviceType: string,
|
|
100
111
|
status: ServiceStatus,
|
|
101
|
-
|
|
102
|
-
error?: string,
|
|
103
|
-
pid?: number,
|
|
104
|
-
pidStartTime?: number,
|
|
112
|
+
details: ServiceStatusChangeDetails,
|
|
105
113
|
) => void
|
|
106
114
|
|
|
107
115
|
constructor(logger: Logger, portPool: PortPool, deps: ServiceExecutorDeps) {
|
|
@@ -115,14 +123,46 @@ export class ServiceExecutor {
|
|
|
115
123
|
sessionId: string,
|
|
116
124
|
serviceType: string,
|
|
117
125
|
status: ServiceStatus,
|
|
118
|
-
|
|
119
|
-
error?: string,
|
|
120
|
-
pid?: number,
|
|
121
|
-
pidStartTime?: number,
|
|
126
|
+
details: ServiceStatusChangeDetails = {},
|
|
122
127
|
): void {
|
|
123
|
-
this.onStatusChanged?.(sessionId, serviceType, status,
|
|
128
|
+
this.onStatusChanged?.(sessionId, serviceType, status, details)
|
|
124
129
|
if (status === 'ready' || status === 'failed' || status === 'stopped') {
|
|
125
|
-
this.resolveWaiters(serviceType, status, error)
|
|
130
|
+
this.resolveWaiters(serviceType, status, details.error)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async isAvailable(config: ServiceConfig, sessionId: SessionId, workspaceDir?: string): Promise<Result<boolean, ToolError>> {
|
|
135
|
+
if (!config.availableWhen) return Ok(true)
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
return Ok(await config.availableWhen({ sessionId: String(sessionId), workspaceDir }))
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
141
|
+
return Err({ message: `Service '${config.type}' availability check failed: ${message}`, recoverable: true })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async resolveCwd(config: ServiceConfig, sessionId: SessionId, workspaceDir?: string): Promise<Result<string | undefined, ToolError>> {
|
|
146
|
+
try {
|
|
147
|
+
if (typeof config.cwd === 'function') {
|
|
148
|
+
if (!workspaceDir) {
|
|
149
|
+
return Err({
|
|
150
|
+
message: `Service '${config.type}' cwd resolver requires a workspaceDir`,
|
|
151
|
+
recoverable: true,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
const raw = await config.cwd({ sessionId: String(sessionId), workspaceDir })
|
|
155
|
+
return Ok(resolve(workspaceDir, raw))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (config.cwd !== undefined) {
|
|
159
|
+
return Ok(workspaceDir ? resolve(workspaceDir, config.cwd) : resolve(config.cwd))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return Ok(workspaceDir)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
165
|
+
return Err({ message: `Service '${config.type}' cwd resolver failed: ${message}`, recoverable: true })
|
|
126
166
|
}
|
|
127
167
|
}
|
|
128
168
|
|
|
@@ -214,44 +254,74 @@ export class ServiceExecutor {
|
|
|
214
254
|
return Ok(undefined)
|
|
215
255
|
}
|
|
216
256
|
|
|
257
|
+
const availability = await this.isAvailable(config, sessionId, workspaceDir)
|
|
258
|
+
if (!availability.ok) return availability
|
|
259
|
+
if (!availability.value) {
|
|
260
|
+
return Err({ message: `Service '${config.type}' is not available in this workspace`, recoverable: true })
|
|
261
|
+
}
|
|
262
|
+
|
|
217
263
|
// Allocate port: reuse session-level allocation, then try preferred, then random
|
|
218
264
|
let port = this.allocatedPorts.get(config.type)
|
|
265
|
+
let allocatedNow = false
|
|
219
266
|
if (port === undefined) {
|
|
220
267
|
port = this.portPool.allocatePreferred(preferredPort) ?? undefined
|
|
221
268
|
if (port === undefined) {
|
|
222
|
-
this.notifyStatusChanged(sessionId, config.type, 'failed',
|
|
269
|
+
this.notifyStatusChanged(sessionId, config.type, 'failed', { error: 'No ports available in pool' })
|
|
223
270
|
return Err({ message: 'No ports available in pool', recoverable: true })
|
|
224
271
|
}
|
|
225
272
|
this.allocatedPorts.set(config.type, port)
|
|
273
|
+
allocatedNow = true
|
|
226
274
|
}
|
|
227
275
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
: config.cwd
|
|
237
|
-
cwd = workspaceDir ? resolve(workspaceDir, raw) : resolve(raw)
|
|
276
|
+
const cwdResult = await this.resolveCwd(config, sessionId, workspaceDir)
|
|
277
|
+
if (!cwdResult.ok) {
|
|
278
|
+
if (allocatedNow) {
|
|
279
|
+
this.allocatedPorts.delete(config.type)
|
|
280
|
+
this.portPool.release(port)
|
|
281
|
+
}
|
|
282
|
+
this.notifyStatusChanged(sessionId, config.type, 'failed', { port, error: cwdResult.error.message })
|
|
283
|
+
return cwdResult
|
|
238
284
|
}
|
|
285
|
+
|
|
286
|
+
const cwd = cwdResult.value
|
|
239
287
|
const logBufferSize = config.logBufferSize ?? 200
|
|
240
288
|
const startupTimeoutMs = config.startupTimeoutMs ?? 30_000
|
|
241
289
|
|
|
242
290
|
const readyRegex = config.readyPattern ? new RegExp(config.readyPattern) : undefined
|
|
243
291
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
292
|
+
const startArgs = {
|
|
293
|
+
port,
|
|
294
|
+
sessionId: String(sessionId),
|
|
295
|
+
workspaceDir,
|
|
296
|
+
cwd,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let command: string
|
|
300
|
+
let serviceEnv: Record<string, string> | undefined
|
|
301
|
+
try {
|
|
302
|
+
command = typeof config.command === 'function'
|
|
303
|
+
? await config.command(startArgs)
|
|
304
|
+
: config.command
|
|
305
|
+
serviceEnv = typeof config.env === 'function'
|
|
306
|
+
? await config.env(startArgs)
|
|
307
|
+
: config.env
|
|
308
|
+
} catch (error) {
|
|
309
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
310
|
+
const errorMessage = `Service '${config.type}' start resolver failed: ${message}`
|
|
311
|
+
if (allocatedNow) {
|
|
312
|
+
this.allocatedPorts.delete(config.type)
|
|
313
|
+
this.portPool.release(port)
|
|
314
|
+
}
|
|
315
|
+
this.notifyStatusChanged(sessionId, config.type, 'failed', { port, cwd, error: errorMessage })
|
|
316
|
+
return Err({ message: errorMessage, recoverable: true })
|
|
317
|
+
}
|
|
248
318
|
|
|
249
319
|
const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'
|
|
250
320
|
const shellFlag = process.platform === 'win32' ? '/c' : '-c'
|
|
251
321
|
|
|
252
322
|
const child = this.processRunner.spawn(shell, [shellFlag, command], {
|
|
253
323
|
cwd,
|
|
254
|
-
env: { ...process.env, ...
|
|
324
|
+
env: { ...process.env, ...serviceEnv, PORT: String(port) },
|
|
255
325
|
detached: true,
|
|
256
326
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
257
327
|
})
|
|
@@ -263,12 +333,20 @@ export class ServiceExecutor {
|
|
|
263
333
|
const current = this.services.get(config.type)
|
|
264
334
|
if (current && current.process === child && current.status !== 'stopped' && current.status !== 'failed') {
|
|
265
335
|
current.status = 'failed'
|
|
266
|
-
this.notifyStatusChanged(sessionId, config.type, 'failed',
|
|
336
|
+
this.notifyStatusChanged(sessionId, config.type, 'failed', {
|
|
337
|
+
error: error.message,
|
|
338
|
+
cwd,
|
|
339
|
+
command,
|
|
340
|
+
})
|
|
267
341
|
}
|
|
268
342
|
})
|
|
269
343
|
|
|
270
344
|
if (!child.pid) {
|
|
271
|
-
|
|
345
|
+
if (allocatedNow) {
|
|
346
|
+
this.allocatedPorts.delete(config.type)
|
|
347
|
+
this.portPool.release(port)
|
|
348
|
+
}
|
|
349
|
+
this.notifyStatusChanged(sessionId, config.type, 'failed', { port, cwd, command, error: 'Failed to spawn process' })
|
|
272
350
|
return Err({ message: 'Failed to spawn service process', recoverable: true })
|
|
273
351
|
}
|
|
274
352
|
|
|
@@ -276,8 +354,8 @@ export class ServiceExecutor {
|
|
|
276
354
|
// "our process" from "an unrelated process that grabbed this PID after ours died"
|
|
277
355
|
const pidStartTime = await getProcessStartTime(this.fs, child.pid)
|
|
278
356
|
|
|
279
|
-
// Emit starting event with PID, port
|
|
280
|
-
this.notifyStatusChanged(sessionId, config.type, 'starting', port,
|
|
357
|
+
// Emit starting event with PID, port, resolved cwd/command, and start time.
|
|
358
|
+
this.notifyStatusChanged(sessionId, config.type, 'starting', { port, pid: child.pid, pidStartTime, cwd, command })
|
|
281
359
|
|
|
282
360
|
const logs = new RingBuffer(logBufferSize)
|
|
283
361
|
const startTime = Date.now()
|
|
@@ -291,10 +369,70 @@ export class ServiceExecutor {
|
|
|
291
369
|
pid: child.pid,
|
|
292
370
|
status: 'starting',
|
|
293
371
|
port,
|
|
372
|
+
cwd,
|
|
373
|
+
command,
|
|
294
374
|
logs,
|
|
295
375
|
}
|
|
296
376
|
this.services.set(config.type, entry)
|
|
297
377
|
|
|
378
|
+
let startupTimer: ReturnType<typeof setTimeout> | undefined
|
|
379
|
+
let readyCheckTimer: ReturnType<typeof setInterval> | undefined
|
|
380
|
+
let readyCheckInFlight = false
|
|
381
|
+
|
|
382
|
+
const clearReadinessTimers = () => {
|
|
383
|
+
if (startupTimer) {
|
|
384
|
+
clearTimeout(startupTimer)
|
|
385
|
+
startupTimer = undefined
|
|
386
|
+
}
|
|
387
|
+
if (readyCheckTimer) {
|
|
388
|
+
clearInterval(readyCheckTimer)
|
|
389
|
+
readyCheckTimer = undefined
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const markReady = (matchedLine?: string) => {
|
|
394
|
+
const current = this.services.get(config.type)
|
|
395
|
+
if (!current || current.process !== child || current.status !== 'starting') return
|
|
396
|
+
|
|
397
|
+
current.status = 'ready'
|
|
398
|
+
clearReadinessTimers()
|
|
399
|
+
this.portConflictRetries.delete(config.type)
|
|
400
|
+
const startupDurationMs = Date.now() - startTime
|
|
401
|
+
this.notifyStatusChanged(sessionId, config.type, 'ready', {
|
|
402
|
+
port: current.port,
|
|
403
|
+
cwd: current.cwd,
|
|
404
|
+
command: current.command,
|
|
405
|
+
})
|
|
406
|
+
this.logger.info('Service ready', {
|
|
407
|
+
serviceType: config.type,
|
|
408
|
+
port: current.port,
|
|
409
|
+
startupDurationMs,
|
|
410
|
+
matchedLine,
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const readyArgs = (): ServiceStartArgs => ({
|
|
415
|
+
...startArgs,
|
|
416
|
+
logs: logs.toArray(),
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
const checkReadyWhen = async () => {
|
|
420
|
+
if (!config.readyWhen || readyCheckInFlight) return
|
|
421
|
+
const current = this.services.get(config.type)
|
|
422
|
+
if (!current || current.process !== child || current.status !== 'starting') return
|
|
423
|
+
|
|
424
|
+
readyCheckInFlight = true
|
|
425
|
+
try {
|
|
426
|
+
if (await config.readyWhen(readyArgs())) {
|
|
427
|
+
markReady()
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
// The service may not be reachable yet; keep polling until timeout.
|
|
431
|
+
} finally {
|
|
432
|
+
readyCheckInFlight = false
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
298
436
|
const processLine = (line: string) => {
|
|
299
437
|
logs.push(line)
|
|
300
438
|
if (PORT_CONFLICT_PATTERN.test(line)) {
|
|
@@ -307,27 +445,17 @@ export class ServiceExecutor {
|
|
|
307
445
|
this.logger.debug('Service output', { serviceType: config.type, line })
|
|
308
446
|
}
|
|
309
447
|
|
|
310
|
-
// Check for ready pattern
|
|
311
448
|
if (readyRegex && current.status === 'starting') {
|
|
312
449
|
if (readyRegex.test(line)) {
|
|
313
|
-
|
|
314
|
-
if (startupTimer) clearTimeout(startupTimer)
|
|
315
|
-
this.portConflictRetries.delete(config.type)
|
|
316
|
-
const startupDurationMs = Date.now() - startTime
|
|
317
|
-
this.notifyStatusChanged(sessionId, config.type, 'ready', current.port)
|
|
318
|
-
this.logger.info('Service ready', {
|
|
319
|
-
serviceType: config.type,
|
|
320
|
-
port: current.port,
|
|
321
|
-
startupDurationMs,
|
|
322
|
-
matchedLine: line,
|
|
323
|
-
})
|
|
450
|
+
markReady(line)
|
|
324
451
|
}
|
|
325
452
|
}
|
|
453
|
+
|
|
454
|
+
void checkReadyWhen()
|
|
326
455
|
}
|
|
327
456
|
|
|
328
457
|
// Startup timeout — mark as failed if not ready in time
|
|
329
|
-
|
|
330
|
-
if (readyRegex) {
|
|
458
|
+
if (readyRegex || config.readyWhen) {
|
|
331
459
|
startupTimer = setTimeout(() => {
|
|
332
460
|
const current = this.services.get(config.type)
|
|
333
461
|
if (!current || current.process !== child || current.status !== 'starting') return
|
|
@@ -335,7 +463,13 @@ export class ServiceExecutor {
|
|
|
335
463
|
current.status = 'failed'
|
|
336
464
|
const errorMsg = `Service startup timed out after ${startupTimeoutMs}ms`
|
|
337
465
|
this.logger.error(errorMsg, undefined, { serviceType: config.type })
|
|
338
|
-
|
|
466
|
+
clearReadinessTimers()
|
|
467
|
+
this.notifyStatusChanged(sessionId, config.type, 'failed', {
|
|
468
|
+
port: current.port,
|
|
469
|
+
cwd: current.cwd,
|
|
470
|
+
command: current.command,
|
|
471
|
+
error: errorMsg,
|
|
472
|
+
})
|
|
339
473
|
|
|
340
474
|
// Kill the timed-out process
|
|
341
475
|
try {
|
|
@@ -346,6 +480,14 @@ export class ServiceExecutor {
|
|
|
346
480
|
}, startupTimeoutMs)
|
|
347
481
|
}
|
|
348
482
|
|
|
483
|
+
if (config.readyWhen) {
|
|
484
|
+
const intervalMs = config.readyCheckIntervalMs ?? 250
|
|
485
|
+
readyCheckTimer = setInterval(() => {
|
|
486
|
+
void checkReadyWhen()
|
|
487
|
+
}, intervalMs)
|
|
488
|
+
void checkReadyWhen()
|
|
489
|
+
}
|
|
490
|
+
|
|
349
491
|
// Pipe stdout/stderr line by line
|
|
350
492
|
let stdoutPartial = ''
|
|
351
493
|
child.stdout?.on('data', (data: Buffer) => {
|
|
@@ -369,7 +511,7 @@ export class ServiceExecutor {
|
|
|
369
511
|
|
|
370
512
|
// Handle unexpected exit
|
|
371
513
|
child.on('close', (code) => {
|
|
372
|
-
|
|
514
|
+
clearReadinessTimers()
|
|
373
515
|
// Flush remaining partial lines
|
|
374
516
|
if (stdoutPartial) {
|
|
375
517
|
processLine(stdoutPartial)
|
|
@@ -413,7 +555,12 @@ export class ServiceExecutor {
|
|
|
413
555
|
// Unexpected exit
|
|
414
556
|
current.status = 'failed'
|
|
415
557
|
const errorMsg = `Process exited unexpectedly with code ${code}`
|
|
416
|
-
this.notifyStatusChanged(sessionId, config.type, 'failed',
|
|
558
|
+
this.notifyStatusChanged(sessionId, config.type, 'failed', {
|
|
559
|
+
port: current.port,
|
|
560
|
+
cwd: current.cwd,
|
|
561
|
+
command: current.command,
|
|
562
|
+
error: errorMsg,
|
|
563
|
+
})
|
|
417
564
|
this.logger.warn('Service process exited unexpectedly', {
|
|
418
565
|
serviceType: config.type,
|
|
419
566
|
code,
|
|
@@ -433,9 +580,8 @@ export class ServiceExecutor {
|
|
|
433
580
|
})
|
|
434
581
|
|
|
435
582
|
// If no ready pattern, immediately mark as ready
|
|
436
|
-
if (!readyRegex) {
|
|
437
|
-
|
|
438
|
-
this.notifyStatusChanged(sessionId, config.type, 'ready', port)
|
|
583
|
+
if (!readyRegex && !config.readyWhen) {
|
|
584
|
+
markReady()
|
|
439
585
|
}
|
|
440
586
|
|
|
441
587
|
return Ok(undefined)
|
|
@@ -564,7 +710,11 @@ export class ServiceExecutor {
|
|
|
564
710
|
}
|
|
565
711
|
|
|
566
712
|
entry.status = 'ready'
|
|
567
|
-
this.notifyStatusChanged(sessionId, config.type, 'ready',
|
|
713
|
+
this.notifyStatusChanged(sessionId, config.type, 'ready', {
|
|
714
|
+
port: entry.port,
|
|
715
|
+
cwd: entry.cwd,
|
|
716
|
+
command: entry.command,
|
|
717
|
+
})
|
|
568
718
|
|
|
569
719
|
this.logger.info('Service resumed', { serviceType: config.type })
|
|
570
720
|
return Ok(undefined)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
|
-
import { readFile, rm } from 'node:fs/promises'
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { MemoryEventStore } from '~/core/events/memory.js'
|
|
@@ -13,7 +13,7 @@ import { createTestPreset, TestHarness } from '~/testing/index.js'
|
|
|
13
13
|
import { serviceEvents, servicePlugin } from './plugin.js'
|
|
14
14
|
import type { ServiceAgentConfig, ServicePluginConfig } from './plugin.js'
|
|
15
15
|
import { PortPool } from './port-pool.js'
|
|
16
|
-
import type { ServiceConfig, ServiceEntry, ServiceStatus } from './schema.js'
|
|
16
|
+
import type { ServiceCommandArgs, ServiceConfig, ServiceCwdArgs, ServiceEntry, ServiceStatus } from './schema.js'
|
|
17
17
|
import { ServiceExecutor } from './service.js'
|
|
18
18
|
|
|
19
19
|
// ============================================================================
|
|
@@ -278,6 +278,84 @@ describe('services plugin', () => {
|
|
|
278
278
|
const serviceState = selectPluginState<Map<string, ServiceEntry>>(session.state, 'services')?.get('callback-service')
|
|
279
279
|
expect(serviceState!.port).toBeGreaterThanOrEqual(10000)
|
|
280
280
|
})
|
|
281
|
+
|
|
282
|
+
it('resolves cwd, command, and env callbacks with runtime context', async () => {
|
|
283
|
+
const workspaceDir = await mkdtemp(join(tmpdir(), 'roj-svc-runtime-'))
|
|
284
|
+
const webDir = join(workspaceDir, 'packages', 'web')
|
|
285
|
+
await mkdir(webDir, { recursive: true })
|
|
286
|
+
|
|
287
|
+
let cwdArgs: ServiceCwdArgs | undefined
|
|
288
|
+
let commandArgs: ServiceCommandArgs | undefined
|
|
289
|
+
let envArgs: ServiceCommandArgs | undefined
|
|
290
|
+
|
|
291
|
+
const runtimeService: ServiceConfig = {
|
|
292
|
+
type: 'runtime',
|
|
293
|
+
description: 'Service with runtime resolvers',
|
|
294
|
+
cwd: (args) => {
|
|
295
|
+
cwdArgs = args
|
|
296
|
+
return 'packages/web'
|
|
297
|
+
},
|
|
298
|
+
command: async (args) => {
|
|
299
|
+
commandArgs = args
|
|
300
|
+
return `printf "%s" "$RUNTIME_MARKER" > marker.txt && echo "READY ${args.port}" && sleep 60`
|
|
301
|
+
},
|
|
302
|
+
env: (args) => {
|
|
303
|
+
envArgs = args
|
|
304
|
+
return {
|
|
305
|
+
RUNTIME_MARKER: `${args.sessionId}|${args.workspaceDir}|${args.cwd}|${args.port}`,
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
readyPattern: 'READY',
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const portPool = new PortPool()
|
|
313
|
+
const harness = createServicesHarness({
|
|
314
|
+
presets: [createServicesPreset([runtimeService], ['runtime'], portPool, { workspaceDir })],
|
|
315
|
+
llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
const session = await harness.createSession('test')
|
|
319
|
+
const entryAgentId = session.getEntryAgentId()!
|
|
320
|
+
const startResult = await session.callPluginMethod('services.start', {
|
|
321
|
+
sessionId: String(session.sessionId),
|
|
322
|
+
agentId: String(entryAgentId),
|
|
323
|
+
serviceType: 'runtime',
|
|
324
|
+
})
|
|
325
|
+
expect(startResult.ok).toBe(true)
|
|
326
|
+
await waitForServiceStatus(session, 'runtime', 'ready')
|
|
327
|
+
|
|
328
|
+
const serviceState = selectPluginState<Map<string, ServiceEntry>>(session.state, 'services')?.get('runtime')
|
|
329
|
+
expect(cwdArgs?.workspaceDir).toBe(workspaceDir)
|
|
330
|
+
expect(cwdArgs?.sessionId).toBe(String(session.sessionId))
|
|
331
|
+
expect(commandArgs?.cwd).toBe(webDir)
|
|
332
|
+
expect(commandArgs?.workspaceDir).toBe(workspaceDir)
|
|
333
|
+
expect(envArgs?.cwd).toBe(webDir)
|
|
334
|
+
expect(serviceState?.cwd).toBe(webDir)
|
|
335
|
+
expect(serviceState?.command).toContain('RUNTIME_MARKER')
|
|
336
|
+
|
|
337
|
+
const marker = await readFile(join(webDir, 'marker.txt'), 'utf-8')
|
|
338
|
+
expect(marker).toContain(String(session.sessionId))
|
|
339
|
+
expect(marker).toContain(workspaceDir)
|
|
340
|
+
expect(marker).toContain(webDir)
|
|
341
|
+
|
|
342
|
+
const statusResult = await session.callPluginMethod('services.status', {
|
|
343
|
+
sessionId: String(session.sessionId),
|
|
344
|
+
agentId: String(entryAgentId),
|
|
345
|
+
serviceType: 'runtime',
|
|
346
|
+
})
|
|
347
|
+
expect(statusResult).toMatchObject({
|
|
348
|
+
ok: true,
|
|
349
|
+
value: {
|
|
350
|
+
status: 'ready',
|
|
351
|
+
cwd: webDir,
|
|
352
|
+
command: serviceState?.command,
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
} finally {
|
|
356
|
+
await rm(workspaceDir, { recursive: true, force: true })
|
|
357
|
+
}
|
|
358
|
+
})
|
|
281
359
|
})
|
|
282
360
|
|
|
283
361
|
// =========================================================================
|
|
@@ -379,6 +457,35 @@ describe('services plugin', () => {
|
|
|
379
457
|
const autoEvents = events.filter((e) => e.serviceType === 'auto-start')
|
|
380
458
|
expect(autoEvents.length).toBeGreaterThanOrEqual(1)
|
|
381
459
|
})
|
|
460
|
+
|
|
461
|
+
it('availableWhen false skips auto-start and explicit start returns an error', async () => {
|
|
462
|
+
const unavailableService: ServiceConfig = {
|
|
463
|
+
type: 'unavailable',
|
|
464
|
+
description: 'Unavailable service',
|
|
465
|
+
command: 'sleep 60',
|
|
466
|
+
autoStart: true,
|
|
467
|
+
availableWhen: () => false,
|
|
468
|
+
}
|
|
469
|
+
const portPool = new PortPool()
|
|
470
|
+
const harness = createServicesHarness({
|
|
471
|
+
presets: [createServicesPreset([unavailableService], ['unavailable'], portPool)],
|
|
472
|
+
llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
const session = await harness.createSession('test')
|
|
476
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
477
|
+
|
|
478
|
+
const events = await session.getEventsByType(serviceEvents, 'service_status_changed')
|
|
479
|
+
expect(events.some((event) => event.serviceType === 'unavailable')).toBe(false)
|
|
480
|
+
|
|
481
|
+
const entryAgentId = session.getEntryAgentId()!
|
|
482
|
+
const result = await session.callPluginMethod('services.start', {
|
|
483
|
+
sessionId: String(session.sessionId),
|
|
484
|
+
agentId: String(entryAgentId),
|
|
485
|
+
serviceType: 'unavailable',
|
|
486
|
+
})
|
|
487
|
+
expect(result.ok).toBe(false)
|
|
488
|
+
})
|
|
382
489
|
})
|
|
383
490
|
|
|
384
491
|
// =========================================================================
|
|
@@ -439,6 +546,38 @@ describe('services plugin', () => {
|
|
|
439
546
|
const serviceState = selectPluginState<Map<string, ServiceEntry>>(session.state, 'services')?.get('ready-service')
|
|
440
547
|
expect(serviceState!.status).toBe('ready')
|
|
441
548
|
})
|
|
549
|
+
|
|
550
|
+
it('readyWhen can mark a service ready without output pattern', async () => {
|
|
551
|
+
let checks = 0
|
|
552
|
+
const readyWhenService: ServiceConfig = {
|
|
553
|
+
type: 'ready-when',
|
|
554
|
+
description: 'Service with callback readiness',
|
|
555
|
+
command: 'sleep 60',
|
|
556
|
+
readyWhen: () => {
|
|
557
|
+
checks += 1
|
|
558
|
+
return checks > 1
|
|
559
|
+
},
|
|
560
|
+
readyCheckIntervalMs: 20,
|
|
561
|
+
startupTimeoutMs: 1000,
|
|
562
|
+
}
|
|
563
|
+
const portPool = new PortPool()
|
|
564
|
+
const harness = createServicesHarness({
|
|
565
|
+
presets: [createServicesPreset([readyWhenService], ['ready-when'], portPool)],
|
|
566
|
+
llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
const session = await harness.createSession('test')
|
|
570
|
+
const entryAgentId = session.getEntryAgentId()!
|
|
571
|
+
const result = await session.callPluginMethod('services.start', {
|
|
572
|
+
sessionId: String(session.sessionId),
|
|
573
|
+
agentId: String(entryAgentId),
|
|
574
|
+
serviceType: 'ready-when',
|
|
575
|
+
})
|
|
576
|
+
expect(result.ok).toBe(true)
|
|
577
|
+
|
|
578
|
+
await waitForServiceStatus(session, 'ready-when', 'ready')
|
|
579
|
+
expect(checks).toBeGreaterThan(1)
|
|
580
|
+
})
|
|
442
581
|
})
|
|
443
582
|
|
|
444
583
|
// =========================================================================
|
|
@@ -658,8 +797,8 @@ describe('services plugin', () => {
|
|
|
658
797
|
}
|
|
659
798
|
|
|
660
799
|
const observed: Array<{ status: ServiceStatus; port?: number }> = []
|
|
661
|
-
executor.onStatusChanged = (_sessionId, _serviceType, status,
|
|
662
|
-
observed.push({ status, port })
|
|
800
|
+
executor.onStatusChanged = (_sessionId, _serviceType, status, details) => {
|
|
801
|
+
observed.push({ status, port: details.port })
|
|
663
802
|
}
|
|
664
803
|
|
|
665
804
|
try {
|
|
@@ -12,6 +12,7 @@ export type TestAgentDefinition<TInput = unknown> = Omit<AgentDefinition<TInput>
|
|
|
12
12
|
*/
|
|
13
13
|
export function createTestPreset(overrides?: {
|
|
14
14
|
id?: string
|
|
15
|
+
workspaceDir?: string
|
|
15
16
|
orchestratorSystem?: string
|
|
16
17
|
agents?: TestAgentDefinition[]
|
|
17
18
|
plugins?: SessionPluginConfig[]
|
|
@@ -22,6 +23,7 @@ export function createTestPreset(overrides?: {
|
|
|
22
23
|
return {
|
|
23
24
|
id: overrides?.id ?? 'test',
|
|
24
25
|
name: 'Test Preset',
|
|
26
|
+
workspaceDir: overrides?.workspaceDir,
|
|
25
27
|
orchestrator: {
|
|
26
28
|
system: overrides?.orchestratorSystem ?? 'You are a test agent.',
|
|
27
29
|
model: ModelId('mock'),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test'
|
|
2
2
|
import { MockLLMProvider } from '~/core/llm/mock.js'
|
|
3
3
|
import { ToolCallId } from '~/core/tools/schema.js'
|
|
4
|
+
import { definePlugin, Ok, selectSessionStats, sessionStatsPlugin, type SessionStatsState, z } from '~/index.js'
|
|
4
5
|
import { createTestPreset, TestHarness } from './index.js'
|
|
5
6
|
|
|
6
7
|
describe('TestHarness', () => {
|
|
@@ -60,4 +61,54 @@ describe('TestHarness', () => {
|
|
|
60
61
|
|
|
61
62
|
await harness.shutdown()
|
|
62
63
|
})
|
|
64
|
+
|
|
65
|
+
it('includes session stats by default and exposes plugin state', async () => {
|
|
66
|
+
expect(sessionStatsPlugin.name).toBe('session-stats')
|
|
67
|
+
|
|
68
|
+
const harness = new TestHarness({
|
|
69
|
+
presets: [createTestPreset()],
|
|
70
|
+
llmProvider: MockLLMProvider.withFixedResponse({
|
|
71
|
+
content: 'Done',
|
|
72
|
+
toolCalls: [],
|
|
73
|
+
metrics: MockLLMProvider.defaultMetricsWithCost(1.25),
|
|
74
|
+
}),
|
|
75
|
+
// Passing a default plugin explicitly should not register it twice.
|
|
76
|
+
systemPlugins: [sessionStatsPlugin],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const session = await harness.createSession('test')
|
|
80
|
+
await session.sendAndWaitForIdle('Test')
|
|
81
|
+
|
|
82
|
+
const stats = session.getPluginState<SessionStatsState>('sessionStats')
|
|
83
|
+
expect(stats?.llmCalls).toBe(1)
|
|
84
|
+
expect(stats?.totalCost).toBe(1.25)
|
|
85
|
+
expect(selectSessionStats(session.state).totalCost).toBe(1.25)
|
|
86
|
+
|
|
87
|
+
await harness.shutdown()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('callPluginMethod can pass caller context', async () => {
|
|
91
|
+
const callerPlugin = definePlugin('caller')
|
|
92
|
+
.method('whoami', {
|
|
93
|
+
input: z.object({}),
|
|
94
|
+
output: z.object({ source: z.string() }),
|
|
95
|
+
handler: async ctx => Ok({ source: ctx.caller.source }),
|
|
96
|
+
})
|
|
97
|
+
.build()
|
|
98
|
+
|
|
99
|
+
const harness = new TestHarness({
|
|
100
|
+
presets: [createTestPreset()],
|
|
101
|
+
systemPlugins: [callerPlugin],
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const session = await harness.createSession('test')
|
|
105
|
+
const result = await session.callPluginMethod('caller.whoami', {}, { caller: { source: 'system', meta: {} } })
|
|
106
|
+
|
|
107
|
+
expect(result.ok).toBe(true)
|
|
108
|
+
if (result.ok) {
|
|
109
|
+
expect(result.value).toEqual({ source: 'system' })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await harness.shutdown()
|
|
113
|
+
})
|
|
63
114
|
})
|