@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.
Files changed (59) hide show
  1. package/dist/bootstrap.d.ts +2 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/plugins/resources/index.d.ts +1 -1
  8. package/dist/plugins/resources/index.d.ts.map +1 -1
  9. package/dist/plugins/resources/plugin.d.ts +7 -1
  10. package/dist/plugins/resources/plugin.d.ts.map +1 -1
  11. package/dist/plugins/resources/plugin.js +25 -5
  12. package/dist/plugins/resources/plugin.js.map +1 -1
  13. package/dist/plugins/resources/resources.integration.test.d.ts +2 -0
  14. package/dist/plugins/resources/resources.integration.test.d.ts.map +1 -0
  15. package/dist/plugins/resources/resources.integration.test.js +62 -0
  16. package/dist/plugins/resources/resources.integration.test.js.map +1 -0
  17. package/dist/plugins/resources/state.d.ts +2 -0
  18. package/dist/plugins/resources/state.d.ts.map +1 -1
  19. package/dist/plugins/resources/state.js +1 -0
  20. package/dist/plugins/resources/state.js.map +1 -1
  21. package/dist/plugins/services/plugin.d.ts +4 -0
  22. package/dist/plugins/services/plugin.d.ts.map +1 -1
  23. package/dist/plugins/services/plugin.js +34 -9
  24. package/dist/plugins/services/plugin.js.map +1 -1
  25. package/dist/plugins/services/prompt.d.ts.map +1 -1
  26. package/dist/plugins/services/prompt.js +3 -0
  27. package/dist/plugins/services/prompt.js.map +1 -1
  28. package/dist/plugins/services/schema.d.ts +38 -4
  29. package/dist/plugins/services/schema.d.ts.map +1 -1
  30. package/dist/plugins/services/service.d.ts +11 -1
  31. package/dist/plugins/services/service.d.ts.map +1 -1
  32. package/dist/plugins/services/service.js +182 -46
  33. package/dist/plugins/services/service.js.map +1 -1
  34. package/dist/plugins/services/services.integration.test.js +128 -3
  35. package/dist/plugins/services/services.integration.test.js.map +1 -1
  36. package/dist/testing/preset-helpers.d.ts +1 -0
  37. package/dist/testing/preset-helpers.d.ts.map +1 -1
  38. package/dist/testing/preset-helpers.js +1 -0
  39. package/dist/testing/preset-helpers.js.map +1 -1
  40. package/dist/testing/test-harness.d.ts +9 -2
  41. package/dist/testing/test-harness.d.ts.map +1 -1
  42. package/dist/testing/test-harness.js +31 -3
  43. package/dist/testing/test-harness.js.map +1 -1
  44. package/dist/testing/test-harness.test.js +41 -0
  45. package/dist/testing/test-harness.test.js.map +1 -1
  46. package/package.json +2 -2
  47. package/src/index.ts +10 -2
  48. package/src/plugins/resources/index.ts +1 -1
  49. package/src/plugins/resources/plugin.ts +35 -6
  50. package/src/plugins/resources/resources.integration.test.ts +68 -0
  51. package/src/plugins/resources/state.ts +2 -0
  52. package/src/plugins/services/plugin.ts +32 -9
  53. package/src/plugins/services/prompt.ts +3 -0
  54. package/src/plugins/services/schema.ts +40 -4
  55. package/src/plugins/services/service.ts +202 -52
  56. package/src/plugins/services/services.integration.test.ts +143 -4
  57. package/src/testing/preset-helpers.ts +2 -0
  58. package/src/testing/test-harness.test.ts +51 -0
  59. package/src/testing/test-harness.ts +37 -4
@@ -1,4 +1,4 @@
1
- import { join, relative } from 'node:path'
1
+ import { join, relative, resolve } from 'node:path'
2
2
  import z from 'zod/v4'
3
3
  import { definePlugin } from '~/core/plugins/plugin-builder.js'
4
4
  import { Ok } from '~/lib/utils/result.js'
@@ -15,8 +15,16 @@ import { type InjectedResource, resourceEvents, type ResourcesState } from './st
15
15
 
16
16
  const MAX_LISTED_PATHS = 100
17
17
 
18
+ export interface ResourcesTargetDirArgs {
19
+ sessionId: string
20
+ sessionDir: string
21
+ workspaceDir?: string
22
+ }
23
+
24
+ export type ResourcesTargetDir = string | ((args: ResourcesTargetDirArgs) => string | Promise<string>)
25
+
18
26
  export interface ResourcesPluginConfig {
19
- targetDir?: string
27
+ targetDir?: ResourcesTargetDir
20
28
  /**
21
29
  * Called after a resource is written/extracted into `targetDir`, before the
22
30
  * `resource_injected` event is emitted. Use `postInjectRules` for a declarative
@@ -61,6 +69,16 @@ async function listFiles(fs: FileSystem, dir: string, maxEntries: number): Promi
61
69
  return results
62
70
  }
63
71
 
72
+ async function resolveTargetDir(targetDir: ResourcesTargetDir | undefined, args: ResourcesTargetDirArgs): Promise<string> {
73
+ const baseDir = args.workspaceDir ?? args.sessionDir
74
+ if (targetDir === undefined) return baseDir
75
+
76
+ const raw = typeof targetDir === 'function'
77
+ ? await targetDir(args)
78
+ : targetDir
79
+ return resolve(baseDir, raw)
80
+ }
81
+
64
82
  export const resourcesPlugin = definePlugin('resources')
65
83
  .pluginConfig<ResourcesPluginConfig>()
66
84
  .events([resourceEvents])
@@ -76,6 +94,7 @@ export const resourcesPlugin = definePlugin('resources')
76
94
  filename: event.filename,
77
95
  mimeType: event.mimeType,
78
96
  paths: event.paths,
97
+ targetDir: event.targetDir,
79
98
  injectedAt: event.injectedAt,
80
99
  }
81
100
  return { resources: [...state.resources, resource] }
@@ -102,7 +121,12 @@ export const resourcesPlugin = definePlugin('resources')
102
121
  handler: async (ctx, input) => {
103
122
  const fs = ctx.platform.fs
104
123
  const exec = makeExec(ctx.platform.process)
105
- const targetDir = ctx.pluginConfig?.targetDir ?? ctx.environment.workspaceDir ?? ctx.environment.sessionDir
124
+ const targetDir = await resolveTargetDir(ctx.pluginConfig?.targetDir, {
125
+ sessionId: String(ctx.sessionId),
126
+ sessionDir: ctx.environment.sessionDir,
127
+ workspaceDir: ctx.environment.workspaceDir,
128
+ })
129
+ await fs.mkdir(targetDir, { recursive: true })
106
130
  const resourceId = crypto.randomUUID()
107
131
  let paths: string[]
108
132
 
@@ -183,6 +207,7 @@ export const resourcesPlugin = definePlugin('resources')
183
207
  filename: input.filename,
184
208
  mimeType: input.mimeType,
185
209
  paths,
210
+ targetDir,
186
211
  injectedAt: Date.now(),
187
212
  }))
188
213
 
@@ -193,12 +218,16 @@ export const resourcesPlugin = definePlugin('resources')
193
218
  const { resources } = ctx.pluginState
194
219
  if (resources.length === 0) return null
195
220
 
196
- const targetDir = ctx.pluginConfig?.targetDir ?? ctx.environment.workspaceDir ?? ctx.environment.sessionDir
197
221
  const lines = resources.map(r => {
198
222
  const label = r.name ?? r.slug ?? r.filename
199
- return `- **${label}** (${r.filename}): ${r.paths.length} files`
223
+ const target = r.targetDir ? ` in \`${r.targetDir}\`` : ''
224
+ return `- **${label}** (${r.filename}): ${r.paths.length} files${target}`
200
225
  })
226
+ const targetDirs = [...new Set(resources.flatMap((r) => r.targetDir ? [r.targetDir] : []))]
227
+ const targetSummary = targetDirs.length === 1
228
+ ? `\`${targetDirs[0]}\``
229
+ : targetDirs.length > 1 ? 'multiple target directories' : 'the configured target directory'
201
230
 
202
- return `## Injected Resources\n\nThe following resources have been extracted into your workspace (\`${targetDir}\`):\n${lines.join('\n')}\n\nThese files are ready to use. Explore them with the filesystem tools.`
231
+ return `## Injected Resources\n\nThe following resources have been extracted into your workspace (${targetSummary}):\n${lines.join('\n')}\n\nThese files are ready to use. Explore them with the filesystem tools.`
203
232
  })
204
233
  .build()
@@ -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, port, error, pid, pidStartTime) => {
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
- pid,
132
- pidStartTime,
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
  }
@@ -42,6 +42,9 @@ export function buildServiceStatusMessage(
42
42
  if (entry?.error) {
43
43
  line += ` (error: ${entry.error})`
44
44
  }
45
+ if (entry?.cwd) {
46
+ line += ` (cwd: ${entry.cwd})`
47
+ }
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 allocated port */
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