@roj-ai/sdk 0.1.27 → 0.1.29
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/core/sessions/session.d.ts.map +1 -1
- package/dist/core/sessions/session.js +6 -2
- package/dist/core/sessions/session.js.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 +38 -10
- 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 +46 -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 +240 -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/core/sessions/session.ts +7 -3
- 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 +36 -10
- package/src/plugins/services/prompt.ts +3 -0
- package/src/plugins/services/schema.ts +48 -4
- package/src/plugins/services/service.ts +202 -52
- package/src/plugins/services/services.integration.test.ts +272 -4
- package/src/testing/preset-helpers.ts +2 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { MockLLMProvider } from '~/core/llm/mock.js'
|
|
6
|
+
import { createTestPreset, TestHarness } from '~/testing/index.js'
|
|
7
|
+
import { resourcesPlugin } from './plugin.js'
|
|
8
|
+
import { resourceEvents, type ResourcesState } from './state.js'
|
|
9
|
+
|
|
10
|
+
let currentHarness: TestHarness | undefined
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
if (currentHarness) {
|
|
14
|
+
await currentHarness.shutdown()
|
|
15
|
+
currentHarness = undefined
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('resources plugin', () => {
|
|
20
|
+
it('resolves targetDir callback relative to workspace and records it in state', async () => {
|
|
21
|
+
const workspaceDir = await mkdtemp(join(tmpdir(), 'roj-resources-'))
|
|
22
|
+
const targetDir = join(workspaceDir, 'packages', 'web')
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const harness = new TestHarness({
|
|
26
|
+
presets: [
|
|
27
|
+
createTestPreset({
|
|
28
|
+
workspaceDir,
|
|
29
|
+
plugins: [
|
|
30
|
+
resourcesPlugin.configure({
|
|
31
|
+
targetDir: ({ sessionId, workspaceDir: resolvedWorkspaceDir }) => {
|
|
32
|
+
expect(sessionId.length).toBeGreaterThan(0)
|
|
33
|
+
expect(resolvedWorkspaceDir).toBe(workspaceDir)
|
|
34
|
+
return 'packages/web'
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
],
|
|
40
|
+
llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
|
|
41
|
+
})
|
|
42
|
+
currentHarness = harness
|
|
43
|
+
|
|
44
|
+
const session = await harness.createSession('test')
|
|
45
|
+
const result = await session.callPluginMethod('resources.inject', {
|
|
46
|
+
sessionId: String(session.sessionId),
|
|
47
|
+
filename: 'hello.txt',
|
|
48
|
+
mimeType: 'text/plain',
|
|
49
|
+
size: 5,
|
|
50
|
+
fileBuffer: Buffer.from('hello'),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(result).toMatchObject({
|
|
54
|
+
ok: true,
|
|
55
|
+
value: { paths: ['hello.txt'] },
|
|
56
|
+
})
|
|
57
|
+
await expect(readFile(join(targetDir, 'hello.txt'), 'utf-8')).resolves.toBe('hello')
|
|
58
|
+
|
|
59
|
+
const resources = session.getPluginState<ResourcesState>('resources')
|
|
60
|
+
expect(resources?.resources[0]?.targetDir).toBe(targetDir)
|
|
61
|
+
|
|
62
|
+
const events = await session.getEventsByType(resourceEvents, 'resource_injected')
|
|
63
|
+
expect(events[0]?.targetDir).toBe(targetDir)
|
|
64
|
+
} finally {
|
|
65
|
+
await rm(workspaceDir, { recursive: true, force: true })
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -10,6 +10,7 @@ export const resourceEvents = createEventsFactory({
|
|
|
10
10
|
filename: z4.string(),
|
|
11
11
|
mimeType: z4.string(),
|
|
12
12
|
paths: z4.array(z4.string()),
|
|
13
|
+
targetDir: z4.string().optional(),
|
|
13
14
|
injectedAt: z4.number(),
|
|
14
15
|
}),
|
|
15
16
|
},
|
|
@@ -22,6 +23,7 @@ export interface InjectedResource {
|
|
|
22
23
|
filename: string
|
|
23
24
|
mimeType: string
|
|
24
25
|
paths: string[]
|
|
26
|
+
targetDir?: string
|
|
25
27
|
injectedAt: number
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -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
|
}
|
|
@@ -445,7 +468,10 @@ export const servicePlugin = definePlugin('services')
|
|
|
445
468
|
})
|
|
446
469
|
if (!result.ok) return Err({ message: result.error.message, recoverable: false })
|
|
447
470
|
|
|
448
|
-
|
|
471
|
+
// Strip cwd: a real host path, meaningless (and misleading) inside
|
|
472
|
+
// the agent's virtual-root namespace.
|
|
473
|
+
const { cwd: _cwd, ...agentSafe } = result.value
|
|
474
|
+
return Ok(JSON.stringify(agentSafe))
|
|
449
475
|
},
|
|
450
476
|
}),
|
|
451
477
|
createTool({
|
|
@@ -42,6 +42,9 @@ export function buildServiceStatusMessage(
|
|
|
42
42
|
if (entry?.error) {
|
|
43
43
|
line += ` (error: ${entry.error})`
|
|
44
44
|
}
|
|
45
|
+
// Deliberately no cwd: it is a real host path, while sandboxed agents see the
|
|
46
|
+
// world through virtual roots — leaking it sends them chasing paths that
|
|
47
|
+
// don't exist in their namespace (see webmaster path-identity incident).
|
|
45
48
|
|
|
46
49
|
lines.push(line)
|
|
47
50
|
}
|
|
@@ -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) */
|
|
@@ -71,6 +103,14 @@ export interface ServiceConfig {
|
|
|
71
103
|
startupTimeoutMs?: number
|
|
72
104
|
/** Auto-pause configuration (interface prepared, not yet implemented) */
|
|
73
105
|
autoPause?: { inactivityMs: number }
|
|
106
|
+
/**
|
|
107
|
+
* When false, the service is hidden from the owning agent: excluded from its
|
|
108
|
+
* service_* tools and the session-context status block. It still runs at the
|
|
109
|
+
* session level and stays fully controllable via the services.* methods —
|
|
110
|
+
* for infrastructure services (e.g. a CMS sidecar) driven by the platform,
|
|
111
|
+
* not by agents. Default: true.
|
|
112
|
+
*/
|
|
113
|
+
agentVisible?: boolean
|
|
74
114
|
}
|
|
75
115
|
|
|
76
116
|
// ============================================================================
|
|
@@ -86,6 +126,10 @@ export interface ServiceEntry {
|
|
|
86
126
|
status: ServiceStatus
|
|
87
127
|
port?: number
|
|
88
128
|
error?: string
|
|
129
|
+
/** Resolved working directory used for the current/last start. */
|
|
130
|
+
cwd?: string
|
|
131
|
+
/** Resolved command used for the current/last start. */
|
|
132
|
+
command?: string
|
|
89
133
|
startedAt?: number
|
|
90
134
|
readyAt?: number
|
|
91
135
|
stoppedAt?: number
|