@roj-ai/sdk 0.1.27 → 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 +2 -2
- package/dist/index.d.ts.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/package.json +2 -2
- package/src/index.ts +8 -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
|
@@ -17,6 +17,8 @@ export const serviceEvents = createEventsFactory({
|
|
|
17
17
|
toStatus: z.enum(['stopped', 'starting', 'ready', 'stopping', 'failed', 'paused']),
|
|
18
18
|
port: z.number().optional(),
|
|
19
19
|
error: z.string().optional(),
|
|
20
|
+
cwd: z.string().optional(),
|
|
21
|
+
command: z.string().optional(),
|
|
20
22
|
pid: z.number().optional(),
|
|
21
23
|
pidStartTime: z.number().optional(),
|
|
22
24
|
}),
|
|
@@ -58,6 +60,8 @@ export const servicePlugin = definePlugin('services')
|
|
|
58
60
|
serviceType: event.serviceType,
|
|
59
61
|
status: event.toStatus,
|
|
60
62
|
port: event.port,
|
|
63
|
+
cwd: event.cwd,
|
|
64
|
+
command: event.command,
|
|
61
65
|
startedAt: event.timestamp,
|
|
62
66
|
pid: event.pid,
|
|
63
67
|
pidStartTime: event.pidStartTime,
|
|
@@ -71,6 +75,8 @@ export const servicePlugin = definePlugin('services')
|
|
|
71
75
|
updated.startedAt = event.timestamp
|
|
72
76
|
updated.error = undefined
|
|
73
77
|
updated.port = event.port
|
|
78
|
+
updated.cwd = event.cwd
|
|
79
|
+
updated.command = event.command
|
|
74
80
|
updated.pid = event.pid
|
|
75
81
|
updated.pidStartTime = event.pidStartTime
|
|
76
82
|
}
|
|
@@ -79,6 +85,12 @@ export const servicePlugin = definePlugin('services')
|
|
|
79
85
|
if (event.port !== undefined) {
|
|
80
86
|
updated.port = event.port
|
|
81
87
|
}
|
|
88
|
+
if (event.cwd !== undefined) {
|
|
89
|
+
updated.cwd = event.cwd
|
|
90
|
+
}
|
|
91
|
+
if (event.command !== undefined) {
|
|
92
|
+
updated.command = event.command
|
|
93
|
+
}
|
|
82
94
|
}
|
|
83
95
|
if (event.toStatus === 'failed' && event.error) {
|
|
84
96
|
updated.error = event.error
|
|
@@ -122,17 +134,19 @@ export const servicePlugin = definePlugin('services')
|
|
|
122
134
|
})
|
|
123
135
|
.context(async (ctx, pluginConfig) => {
|
|
124
136
|
const executor = new ServiceExecutor(ctx.logger, pluginConfig.portPool, { fs: ctx.platform.fs, process: ctx.platform.process })
|
|
125
|
-
executor.onStatusChanged = (sessionId, serviceType, status,
|
|
137
|
+
executor.onStatusChanged = (sessionId, serviceType, status, details) => {
|
|
126
138
|
void ctx.emitEvent(serviceEvents.create('service_status_changed', {
|
|
127
139
|
serviceType,
|
|
128
140
|
toStatus: status,
|
|
129
|
-
port,
|
|
130
|
-
error,
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
port: details.port,
|
|
142
|
+
error: details.error,
|
|
143
|
+
cwd: details.cwd,
|
|
144
|
+
command: details.command,
|
|
145
|
+
pid: details.pid,
|
|
146
|
+
pidStartTime: details.pidStartTime,
|
|
133
147
|
}))
|
|
134
148
|
// Broadcast service status to connected clients (DO → SPA) via WS
|
|
135
|
-
ctx.notify('serviceStatus', { sessionId: String(sessionId), serviceType, status, port })
|
|
149
|
+
ctx.notify('serviceStatus', { sessionId: String(sessionId), serviceType, status, port: details.port })
|
|
136
150
|
}
|
|
137
151
|
return { executor }
|
|
138
152
|
})
|
|
@@ -160,7 +174,8 @@ export const servicePlugin = definePlugin('services')
|
|
|
160
174
|
}
|
|
161
175
|
|
|
162
176
|
const preferredPort = ctx.pluginState.get(input.serviceType)?.port
|
|
163
|
-
await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
|
|
177
|
+
const startResult = await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
|
|
178
|
+
if (!startResult.ok) return Err(ValidationErrors.invalid(startResult.error.message))
|
|
164
179
|
started.push(input.serviceType)
|
|
165
180
|
} else {
|
|
166
181
|
for (const svcConfig of ctx.pluginConfig.services) {
|
|
@@ -174,7 +189,8 @@ export const servicePlugin = definePlugin('services')
|
|
|
174
189
|
}
|
|
175
190
|
} else {
|
|
176
191
|
const preferredPort = ctx.pluginState.get(svcConfig.type)?.port
|
|
177
|
-
await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
|
|
192
|
+
const startResult = await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
|
|
193
|
+
if (!startResult.ok) return Err(ValidationErrors.invalid(startResult.error.message))
|
|
178
194
|
started.push(svcConfig.type)
|
|
179
195
|
}
|
|
180
196
|
}
|
|
@@ -250,6 +266,8 @@ export const servicePlugin = definePlugin('services')
|
|
|
250
266
|
serviceType: z.string(),
|
|
251
267
|
status: z.string(),
|
|
252
268
|
port: z.number().optional(),
|
|
269
|
+
cwd: z.string().optional(),
|
|
270
|
+
command: z.string().optional(),
|
|
253
271
|
error: z.string().optional(),
|
|
254
272
|
recentLogs: z.array(z.string()),
|
|
255
273
|
}),
|
|
@@ -262,6 +280,8 @@ export const servicePlugin = definePlugin('services')
|
|
|
262
280
|
serviceType: input.serviceType,
|
|
263
281
|
status: status ?? 'stopped',
|
|
264
282
|
port: stateEntry?.port,
|
|
283
|
+
cwd: stateEntry?.cwd,
|
|
284
|
+
command: stateEntry?.command,
|
|
265
285
|
error: stateEntry?.error,
|
|
266
286
|
recentLogs: logsResult.ok ? logsResult.value : [],
|
|
267
287
|
})
|
|
@@ -340,7 +360,10 @@ export const servicePlugin = definePlugin('services')
|
|
|
340
360
|
const status = ctx.pluginContext.executor.getStatus(svcConfig.type)
|
|
341
361
|
if (status !== 'ready' && status !== 'starting') {
|
|
342
362
|
const preferredPort = ctx.pluginState.get(svcConfig.type)?.port
|
|
343
|
-
await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
|
|
363
|
+
const startResult = await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
|
|
364
|
+
if (!startResult.ok) {
|
|
365
|
+
ctx.logger.debug('Auto-start service skipped', { serviceType: svcConfig.type, error: startResult.error.message })
|
|
366
|
+
}
|
|
344
367
|
}
|
|
345
368
|
}
|
|
346
369
|
}
|
|
@@ -24,6 +24,12 @@ export type ServiceStatus = 'stopped' | 'starting' | 'ready' | 'stopping' | 'fai
|
|
|
24
24
|
*/
|
|
25
25
|
export interface ServiceCommandArgs {
|
|
26
26
|
port: number
|
|
27
|
+
/** Session id of the service owner. */
|
|
28
|
+
sessionId: string
|
|
29
|
+
/** Absolute path of the session workspace directory, when configured. */
|
|
30
|
+
workspaceDir?: string
|
|
31
|
+
/** Resolved working directory that will be passed to the child process. */
|
|
32
|
+
cwd?: string
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/**
|
|
@@ -32,10 +38,30 @@ export interface ServiceCommandArgs {
|
|
|
32
38
|
* to locate the web app inside a monorepo before launching the dev server.
|
|
33
39
|
*/
|
|
34
40
|
export interface ServiceCwdArgs {
|
|
41
|
+
/** Session id of the service owner. */
|
|
42
|
+
sessionId: string
|
|
35
43
|
/** Absolute path of the session workspace directory. */
|
|
36
44
|
workspaceDir: string
|
|
37
45
|
}
|
|
38
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Arguments passed to service environment and readiness callbacks.
|
|
49
|
+
*/
|
|
50
|
+
export interface ServiceStartArgs extends ServiceCommandArgs {
|
|
51
|
+
/** Recently captured service output lines. Empty before the process writes. */
|
|
52
|
+
logs: readonly string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Arguments passed to `availableWhen` before a service is started.
|
|
57
|
+
*/
|
|
58
|
+
export interface ServiceAvailabilityArgs {
|
|
59
|
+
/** Session id of the service owner. */
|
|
60
|
+
sessionId: string
|
|
61
|
+
/** Absolute path of the session workspace directory, when configured. */
|
|
62
|
+
workspaceDir?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
/**
|
|
40
66
|
* Service configuration declared inline in presets.
|
|
41
67
|
* Defines how to spawn and monitor a background process.
|
|
@@ -45,8 +71,8 @@ export interface ServiceConfig {
|
|
|
45
71
|
type: string
|
|
46
72
|
/** Human-readable description */
|
|
47
73
|
description: string
|
|
48
|
-
/** Shell command to run, or a callback receiving
|
|
49
|
-
command: string | ((args: ServiceCommandArgs) => string)
|
|
74
|
+
/** Shell command to run, or a callback receiving runtime start context */
|
|
75
|
+
command: string | ((args: ServiceCommandArgs) => string | Promise<string>)
|
|
50
76
|
/**
|
|
51
77
|
* Working directory (defaults to the workspace dir).
|
|
52
78
|
*
|
|
@@ -57,12 +83,18 @@ export interface ServiceConfig {
|
|
|
57
83
|
* resolved the same way.
|
|
58
84
|
*/
|
|
59
85
|
cwd?: string | ((args: ServiceCwdArgs) => string | Promise<string>)
|
|
60
|
-
/** Additional environment variables */
|
|
61
|
-
env?: Record<string, string>
|
|
86
|
+
/** Additional environment variables, resolved after cwd and before spawn */
|
|
87
|
+
env?: Record<string, string> | ((args: ServiceCommandArgs) => Record<string, string> | Promise<Record<string, string>>)
|
|
88
|
+
/** Return false to make start/autoStart skip this service for the current workspace. */
|
|
89
|
+
availableWhen?: (args: ServiceAvailabilityArgs) => boolean | Promise<boolean>
|
|
62
90
|
/** Start automatically with session (default: false) */
|
|
63
91
|
autoStart?: boolean
|
|
64
92
|
/** Regex pattern to detect "ready" in stdout */
|
|
65
93
|
readyPattern?: string
|
|
94
|
+
/** Callback polled until it reports that the service is ready. */
|
|
95
|
+
readyWhen?: (args: ServiceStartArgs) => boolean | Promise<boolean>
|
|
96
|
+
/** Poll interval for readyWhen in ms (default: 250) */
|
|
97
|
+
readyCheckIntervalMs?: number
|
|
66
98
|
/** SIGTERM→SIGKILL timeout in ms (default: 5000) */
|
|
67
99
|
gracefulStopMs?: number
|
|
68
100
|
/** Ring buffer size for log lines (default: 200) */
|
|
@@ -86,6 +118,10 @@ export interface ServiceEntry {
|
|
|
86
118
|
status: ServiceStatus
|
|
87
119
|
port?: number
|
|
88
120
|
error?: string
|
|
121
|
+
/** Resolved working directory used for the current/last start. */
|
|
122
|
+
cwd?: string
|
|
123
|
+
/** Resolved command used for the current/last start. */
|
|
124
|
+
command?: string
|
|
89
125
|
startedAt?: number
|
|
90
126
|
readyAt?: number
|
|
91
127
|
stoppedAt?: number
|
|
@@ -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)
|