acrylblends 0.2.0 → 0.3.0

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 (109) hide show
  1. package/dist/index.d.ts +17 -16
  2. package/dist/index.js +39 -19
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/acryl-inspect/LICENSE +21 -0
  6. package/plugins/acryl-inspect/plugin.json +11 -0
  7. package/plugins/acryl-inspect/src/index.ts +3 -0
  8. package/plugins/acryl-inspect/src/report.ts +123 -0
  9. package/plugins/acryl-inspect/src/service.ts +66 -0
  10. package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
  11. package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
  12. package/plugins/acryl-inspect/upstream.json +18 -0
  13. package/plugins/acryl-node-server/LICENSE +21 -0
  14. package/plugins/acryl-node-server/plugin.json +12 -0
  15. package/plugins/acryl-node-server/src/index.ts +94 -0
  16. package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
  17. package/plugins/acryl-node-server/upstream.json +18 -0
  18. package/plugins/acryl-router/LICENSE +21 -0
  19. package/plugins/acryl-router/plugin.json +12 -0
  20. package/plugins/acryl-router/src/compose.ts +21 -0
  21. package/plugins/acryl-router/src/context.ts +59 -0
  22. package/plugins/acryl-router/src/helpers/index.ts +3 -0
  23. package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
  24. package/plugins/acryl-router/src/helpers/sse.ts +83 -0
  25. package/plugins/acryl-router/src/helpers/validate.ts +44 -0
  26. package/plugins/acryl-router/src/http-error.ts +21 -0
  27. package/plugins/acryl-router/src/index.ts +6 -0
  28. package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
  29. package/plugins/acryl-router/src/middleware/cors.ts +46 -0
  30. package/plugins/acryl-router/src/middleware/index.ts +6 -0
  31. package/plugins/acryl-router/src/middleware/logger.ts +21 -0
  32. package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
  33. package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
  34. package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
  35. package/plugins/acryl-router/src/pattern.ts +89 -0
  36. package/plugins/acryl-router/src/registrar.ts +90 -0
  37. package/plugins/acryl-router/src/service.ts +164 -0
  38. package/plugins/acryl-router/src/table.ts +156 -0
  39. package/plugins/acryl-router/src/types.ts +73 -0
  40. package/plugins/acryl-router/src/util.ts +23 -0
  41. package/plugins/acryl-router/tests/api.spec.ts +223 -0
  42. package/plugins/acryl-router/tests/conformance.ts +170 -0
  43. package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
  44. package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
  45. package/plugins/acryl-router/tests/loader.spec.ts +71 -0
  46. package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
  47. package/plugins/acryl-router/tests/service.spec.ts +211 -0
  48. package/plugins/acryl-router/tests/table.spec.ts +47 -0
  49. package/plugins/acryl-router/upstream.json +20 -0
  50. package/plugins/acryl-router-http/LICENSE +21 -0
  51. package/plugins/acryl-router-http/plugin.json +11 -0
  52. package/plugins/acryl-router-http/src/bridge.ts +52 -0
  53. package/plugins/acryl-router-http/src/index.ts +63 -0
  54. package/plugins/acryl-router-http/src/serve.ts +24 -0
  55. package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
  56. package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
  57. package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
  58. package/plugins/acryl-router-http/upstream.json +22 -0
  59. package/plugins/acryl-state/LICENSE +21 -0
  60. package/plugins/acryl-state/plugin.json +12 -0
  61. package/plugins/acryl-state/src/cell.ts +67 -0
  62. package/plugins/acryl-state/src/index.ts +3 -0
  63. package/plugins/acryl-state/src/react.ts +11 -0
  64. package/plugins/acryl-state/src/service.ts +101 -0
  65. package/plugins/acryl-state/tests/cell.spec.ts +71 -0
  66. package/plugins/acryl-state/tests/react.spec.ts +39 -0
  67. package/plugins/acryl-state/tests/service.spec.ts +132 -0
  68. package/plugins/acryl-state/upstream.json +20 -0
  69. package/plugins/builder/script.ts +8 -9
  70. package/plugins/extensions/index.ts +18 -1
  71. package/plugins/openai/prompt.ts +9 -5
  72. package/plugins/tasks/index.ts +14 -0
  73. package/plugins/tsconfig.json +9 -1
  74. package/templates/agent/blend.yaml +5 -7
  75. package/templates/agent/src/app.ts +8 -8
  76. package/templates/agent/src/routes.ts +32 -41
  77. package/templates/agent/template.json +3 -3
  78. package/templates/agent/test/builder.test.ts +22 -14
  79. package/templates/agent/test/model.test.ts +2 -3
  80. package/templates/{app → blank}/blend.yaml +3 -3
  81. package/templates/{app → blank}/src/app.ts +5 -5
  82. package/templates/blank/src/routes.ts +14 -0
  83. package/templates/blank/template.json +1 -0
  84. package/templates/{app → blank}/test/app.test.ts +1 -1
  85. package/templates/todo/README.md +27 -0
  86. package/templates/todo/blend.yaml +25 -0
  87. package/templates/todo/gitignore +2 -0
  88. package/templates/todo/package.json +23 -0
  89. package/templates/todo/src/app.ts +71 -0
  90. package/templates/todo/src/main.ts +19 -0
  91. package/templates/todo/src/page.ts +28 -0
  92. package/templates/todo/src/todo.ts +96 -0
  93. package/templates/todo/template.json +1 -0
  94. package/templates/todo/test/todo.test.ts +145 -0
  95. package/templates/todo/tsconfig.json +19 -0
  96. package/templates/todo/upstream.json +12 -0
  97. package/plugins/events/index.ts +0 -42
  98. package/plugins/events/plugin.json +0 -9
  99. package/plugins/http/index.ts +0 -64
  100. package/plugins/http/plugin.json +0 -10
  101. package/plugins/server/index.ts +0 -138
  102. package/plugins/server/plugin.json +0 -10
  103. package/templates/app/src/routes.ts +0 -14
  104. package/templates/app/template.json +0 -1
  105. /package/templates/{app → blank}/README.md +0 -0
  106. /package/templates/{app → blank}/gitignore +0 -0
  107. /package/templates/{app → blank}/package.json +0 -0
  108. /package/templates/{app → blank}/src/main.ts +0 -0
  109. /package/templates/{app → blank}/tsconfig.json +0 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The state service through a real Cordis `Context`: cells are owned by the plugin that made them, readable and
3
+ * subscribable in process, and streamed through the router as server-sent events, with cancellation and disposal.
4
+ */
5
+
6
+ import { Context } from '@deepseek-ai/cordis'
7
+ import { apply as routerApply, name as routerName } from '../../acryl-router/src/index.ts'
8
+ import { afterEach, describe, expect, it } from 'vitest'
9
+ import { apply, name } from '../src/index.ts'
10
+
11
+ const contexts: Context[] = []
12
+ const settle = (ms = 0): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
13
+ afterEach(async () => {
14
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
15
+ })
16
+
17
+ async function system(withRouter = true): Promise<Context> {
18
+ const ctx = new Context()
19
+ contexts.push(ctx)
20
+ if (withRouter) ctx.plugin({ name: routerName, apply: routerApply }, { trailingSlash: 'strict' })
21
+ ctx.plugin({ name, apply })
22
+ await settle()
23
+ return ctx
24
+ }
25
+
26
+ /** A tiny SSE reader over a Response body. */
27
+ async function events(response: Response, count: number): Promise<string[]> {
28
+ const reader = response.body!.getReader()
29
+ const decoder = new TextDecoder()
30
+ let buffer = ''
31
+ const out: string[] = []
32
+ while (out.length < count) {
33
+ const { done, value } = await reader.read()
34
+ if (done) break
35
+ buffer += decoder.decode(value, { stream: true })
36
+ for (let at = buffer.indexOf('\n\n'); at !== -1; at = buffer.indexOf('\n\n')) {
37
+ out.push(buffer.slice(0, at))
38
+ buffer = buffer.slice(at + 2)
39
+ }
40
+ }
41
+ reader.releaseLock()
42
+ return out
43
+ }
44
+
45
+ describe('acryl-state', () => {
46
+ it('creates cells owned by the calling plugin and removes them when it unloads', async () => {
47
+ const ctx = await system()
48
+ const fiber = ctx.plugin({ name: 'owner', inject: ['state'], apply(c: Context) { c.state.cell('owner.count', 1) } })
49
+ await settle()
50
+ expect(ctx.state.keys()).toEqual(['owner.count'])
51
+ expect(ctx.state.find<number>('owner.count')?.get()).toBe(1)
52
+ await fiber.dispose()
53
+ await settle()
54
+ expect(ctx.state.keys()).toEqual([])
55
+ expect(ctx.state.find('owner.count')).toBeUndefined()
56
+ })
57
+
58
+ it('rejects a bad or duplicate key', async () => {
59
+ const ctx = await system()
60
+ ctx.state.cell('ok.key', 0)
61
+ expect(() => ctx.state.cell('ok.key', 1)).toThrow(/already in use/)
62
+ expect(() => ctx.state.cell('Bad Key', 1)).toThrow(/dotted lowercase/)
63
+ expect(() => ctx.state.derive('ok.key', [], () => 1)).toThrow(/already in use/)
64
+ })
65
+
66
+ it('derives a cell from others and detaches it when its owner unloads', async () => {
67
+ const ctx = await system()
68
+ const a = ctx.state.cell<number>('n.a', 1)
69
+ const fiber = ctx.plugin({ name: 'deriver', inject: ['state'], apply(c: Context) { c.state.derive('n.double', [a] as const, x => x * 2) } })
70
+ await settle()
71
+ expect(ctx.state.find<number>('n.double')?.get()).toBe(2)
72
+ a.set(5)
73
+ expect(ctx.state.find<number>('n.double')?.get()).toBe(10)
74
+ await fiber.dispose()
75
+ await settle()
76
+ expect(ctx.state.find('n.double')).toBeUndefined()
77
+ })
78
+
79
+ it('serves keys and values as JSON on the router, read-only', async () => {
80
+ const ctx = await system()
81
+ ctx.state.cell('app.title', 'hello')
82
+ const keys = await ctx.router.fetch(new Request('http://acryl.test/api/state'))
83
+ expect(await keys.json()).toEqual({ keys: ['app.title'] })
84
+ const one = await ctx.router.fetch(new Request('http://acryl.test/api/state/app.title'))
85
+ expect(await one.json()).toEqual({ key: 'app.title', value: 'hello' })
86
+ expect((await ctx.router.fetch(new Request('http://acryl.test/api/state/none'))).status).toBe(404)
87
+ expect((await ctx.router.fetch(new Request('http://acryl.test/api/state/app.title', { method: 'PUT', body: '"x"' }))).status).toBe(405)
88
+ expect(ctx.state.find('app.title')?.get()).toBe('hello')
89
+ })
90
+
91
+ it('streams the value now and every change as server-sent events, and ends when the caller aborts', async () => {
92
+ const ctx = await system()
93
+ const cell = ctx.state.cell<number>('app.count', 0)
94
+ const controller = new AbortController()
95
+ const response = await ctx.router.fetch(new Request('http://acryl.test/api/state/app.count/events', { signal: controller.signal }))
96
+ expect(response.headers.get('content-type')).toContain('text/event-stream')
97
+ const reading = events(response, 3)
98
+ await settle(5)
99
+ cell.set(1)
100
+ cell.set(2)
101
+ expect(await reading).toEqual(['event: value\ndata: 0', 'event: value\ndata: 1', 'event: value\ndata: 2'])
102
+ controller.abort()
103
+ await settle(5)
104
+ // After the abort the stream no longer holds a subscription on the cell.
105
+ let notified = 0
106
+ const probe = ctx.state.find<number>('app.count')!.subscribe(() => { notified += 1 })
107
+ cell.set(3)
108
+ probe()
109
+ expect(notified).toBe(1)
110
+ })
111
+
112
+ it('closes every open stream when the router is disposed', async () => {
113
+ const ctx = await system()
114
+ ctx.state.cell<number>('app.count', 0)
115
+ const routerFiber = [...[...ctx.registry.values()].find(runtime => runtime.name === routerName)!.fibers][0]!
116
+ const response = await ctx.router.fetch(new Request('http://acryl.test/api/state/app.count/events'))
117
+ const reader = response.body!.getReader()
118
+ await reader.read()
119
+ const ended = reader.read()
120
+ await routerFiber.dispose()
121
+ await expect(ended).resolves.toMatchObject({ done: true })
122
+ })
123
+
124
+ it('works with no router: cells and subscriptions only, no routes', async () => {
125
+ const ctx = await system(false)
126
+ const cell = ctx.state.cell<string>('plain.value', 'a')
127
+ const seen: string[] = []
128
+ cell.subscribe(value => seen.push(value))
129
+ cell.set('b')
130
+ expect(seen).toEqual(['b'])
131
+ })
132
+ })
@@ -0,0 +1,20 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "path": "plugins/acryl-state",
5
+ "commit": "11fed295161c0282d144b5ac62616988b58d8189"
6
+ },
7
+ "vendored": [
8
+ "src",
9
+ "tests",
10
+ "LICENSE"
11
+ ],
12
+ "rewrittenSpecifiers": 4,
13
+ "digest": "sha256:528bdc35dbbe22dfae8822fe054cfbfccae9554b9e06089dd7e269f44e27add4",
14
+ "testsNotRun": {
15
+ "tests/react.spec.ts": "needs react and @testing-library/react, which generated apps do not install"
16
+ },
17
+ "notice": [
18
+ "LICENSE"
19
+ ]
20
+ }
@@ -21,21 +21,20 @@ export function routeSource(name: string, path: string, text: string): string {
21
21
 
22
22
  /** Written by the builder: answers GET ${path}, and checks that it does before the install counts. */
23
23
  export const name = '${name}'
24
- export const inject = ['http']
24
+ export const inject = ['router']
25
25
 
26
- interface Http {
27
- route(method: string, path: string, handler: () => { body: unknown }): () => void
28
- handle(request: { method: string; path: string; body: unknown }): Promise<{ status: number; body: unknown }>
26
+ interface Router {
27
+ get(path: string, handler: (c: { json(body: unknown, status?: number): Response }) => Response): unknown
28
+ request(path: string): Promise<Response>
29
29
  }
30
-
31
30
  export function apply(ctx: Context) {
32
- const http = ctx.get('http') as Http
33
- ctx.effect(() => http.route('GET', ${JSON.stringify(path)}, () => ({ body: { text: ${JSON.stringify(text)} } })))
31
+ // A route registered through this plugin's context is removed when the plugin is unloaded.
32
+ (ctx as unknown as { router: Router }).router.get(${JSON.stringify(path)}, (c) => c.json({ text: ${JSON.stringify(text)} }))
34
33
  }
35
34
 
36
35
  export async function check(ctx: Context) {
37
- const reply = await (ctx.get('http') as Http).handle({ method: 'GET', path: ${JSON.stringify(path)}, body: undefined })
38
- if (reply.status !== 200) throw new Error(\`GET ${path} answered \${reply.status}\`)
36
+ const reply = await (ctx.get('router') as Router).request(${JSON.stringify(path)})
37
+ if (!reply.ok) throw new Error(\`GET ${path} answered \${reply.status}\`)
39
38
  }
40
39
  `
41
40
  }
@@ -181,6 +181,11 @@ function injected(module: PluginModule): string[] {
181
181
  }
182
182
 
183
183
  /** The service. One instance serves one app home. */
184
+ /** The slice of the state service this plugin uses, by name, so it imports nothing from acryl-state. */
185
+ interface StateService {
186
+ cell<T>(key: string, initial: T): { set(value: T): void }
187
+ }
188
+
184
189
  export class Extensions extends Service {
185
190
  readonly sources: readonly ExtensionSource[]
186
191
  readonly stageRoot: string
@@ -195,9 +200,15 @@ export class Extensions extends Service {
195
200
  private readonly requests = new Map<string, { dir: string; version: string }>()
196
201
  private tail: Promise<unknown> = Promise.resolve()
197
202
  private disposed = false
203
+ /**
204
+ * The context the service was constructed with. Inside a service method `this.ctx` is Cordis's shadow context, which resolves services as the
205
+ * service's own fiber, so a plugin mounted from it would read `ctx.router` from the installer's fiber and fail. Extensions mount from this one.
206
+ */
207
+ private readonly owner: Context
198
208
 
199
209
  constructor(ctx: Context, config: ExtensionsConfig = {}) {
200
210
  super(ctx, 'extensions')
211
+ this.owner = ctx
201
212
  const { home, root } = ctx.appInstance
202
213
  this.sources = (config.sources ?? [{ dir: join(home, 'extensions'), trust: 'install' }]).map(
203
214
  (source) => ({
@@ -228,6 +239,12 @@ export class Extensions extends Service {
228
239
  },
229
240
  'extensions: settle pending operations',
230
241
  )
242
+ // With the state service mounted, what is installed is also a cell: any surface reads it as JSON or streams its changes (`/api/state/extensions`).
243
+ ctx.inject(['state'], (inner) => {
244
+ const state = (inner as unknown as { state: StateService }).state
245
+ const cell = state.cell('extensions', this.list())
246
+ inner.on('extensions/changed', () => cell.set(this.list()))
247
+ })
231
248
  this.ready = this.serial(() => this.startup())
232
249
  }
233
250
 
@@ -512,7 +529,7 @@ export class Extensions extends Service {
512
529
  /** Mount a module as a child fiber and wait until it settles. */
513
530
  private async mount(module: PluginModule): Promise<{ fiber: Fiber; error?: unknown }> {
514
531
  // An extension is a plugin module whose config, if any, comes from its own defaults.
515
- const fiber = this.ctx.plugin(module as unknown as Parameters<Context['plugin']>[0])
532
+ const fiber = this.owner.plugin(module as unknown as Parameters<Context['plugin']>[0])
516
533
  try {
517
534
  await fiber.await()
518
535
  return { fiber }
@@ -11,16 +11,20 @@ A plugin's index.ts is TypeScript that Node runs directly (erasable syntax only:
11
11
 
12
12
  import type { Context } from '@deepseek-ai/cordis'
13
13
  export const name = 'hello' // must equal the folder name
14
- export const inject = ['http'] // services it needs; it waits (pending) until they exist
14
+ export const inject = ['router'] // services it needs; it waits (pending) until they exist
15
+ interface Router { get(path: string, handler: (c: { json(body: unknown, status?: number): Response }) => Response): unknown; request(path: string): Promise<Response> }
15
16
  export function apply(ctx: Context) {
16
- const http = ctx.get('http') as { route(method: string, path: string, handler: (request: { body: unknown }) => { status?: number; body: unknown }): () => void }
17
- ctx.effect(() => http.route('GET', '/hello', () => ({ body: { text: 'Hello' } }))) // in ctx.effect: removed when the plugin is
17
+ ;(ctx as unknown as { router: Router }).router.get('/hello', (c) => c.json({ text: 'Hello' })) // removed when the plugin is unloaded
18
18
  }
19
19
  export async function check(ctx: Context) { // optional: must pass, or the install is rolled back
20
- const http = ctx.get('http') as { handle(r: { method: string; path: string; body: unknown }): Promise<{ status: number }> }
21
- if ((await http.handle({ method: 'GET', path: '/hello', body: undefined })).status !== 200) throw new Error('GET /hello failed')
20
+ const reply = await (ctx.get('router') as Router).request('/hello')
21
+ if (!reply.ok) throw new Error('GET /hello failed')
22
22
  }
23
23
 
24
+ The router: router.get/post/put/delete(path, handler), a path parameter is :id and is read with c.param('id'), c.json(body, status)
25
+ answers JSON, c.text(...) answers text, c.req.json() reads a JSON body, and router.request('/path', { method: 'POST', body: ... }) calls a route in
26
+ process (use it in check). Every registration made from a plugin's own context goes away when the plugin is unloaded.
27
+
24
28
  For a capability that is a pure function of data (parse, convert, compute), write a sandboxed TOOL instead of a plugin. It cannot touch the
25
29
  host: it runs in a separate process that can read only its own folder. Files: index.ts exporting run(input) and tool.json:
26
30
  { "description": "what it does and its input", "examples": [ { "input": {...}, "output": {...} } ] }
@@ -69,6 +69,14 @@ declare module '@deepseek-ai/cordis' {
69
69
  type Change = Partial<TaskRecord> & { readonly id: string }
70
70
  const changeOf = (change: TaskChange) => change as unknown as Change
71
71
 
72
+ /** The slice of the state service the tasks plugin uses, by name, so it imports nothing from acryl-state. */
73
+ interface StateService {
74
+ cell<T>(key: string, initial: T): { set(value: T): void }
75
+ }
76
+
77
+ /** How many of the newest tasks the state cell holds. */
78
+ const STATE_LIMIT = 100
79
+
72
80
  const FINAL: ReadonlySet<TaskStatus> = new Set(['completed', 'failed', 'cancelled', 'interrupted'])
73
81
  const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
74
82
 
@@ -111,6 +119,12 @@ export class Tasks extends Service {
111
119
  },
112
120
  'tasks: cancel running work',
113
121
  )
122
+ // With the state service mounted, the latest tasks are also a cell: any surface reads them as JSON or streams their changes (`/api/state/tasks`).
123
+ ctx.inject(['state'], (inner) => {
124
+ const state = (inner as unknown as { state: StateService }).state
125
+ const cell = state.cell('tasks', this.list().slice(0, STATE_LIMIT))
126
+ inner.on('tasks/changed', () => cell.set(this.list().slice(0, STATE_LIMIT)))
127
+ })
114
128
  }
115
129
 
116
130
  /** Start a task. The record is written before this returns. */
@@ -15,5 +15,13 @@
15
15
  "skipLibCheck": true,
16
16
  "types": ["node"]
17
17
  },
18
- "include": ["*/**/*.ts"]
18
+ "include": ["*/**/*.ts"],
19
+ "exclude": [
20
+ "acryl-router/tests/loader.spec.ts",
21
+ "acryl-state/src/react.ts",
22
+ "acryl-state/tests/react.spec.ts",
23
+ "acryl-router-http/tests/adapter.spec.ts",
24
+ "acryl-router-http/tests/stream.spec.ts",
25
+ "acryl-router-http/tests/surfaces.spec.ts"
26
+ ]
19
27
  }
@@ -34,12 +34,10 @@ spec:
34
34
  name: agent
35
35
  config:
36
36
  steps: "{{parameters.steps}}"
37
- - id: http
38
- name: http
39
- - id: events
40
- name: events
41
- config:
42
- topics: [tasks/changed, extensions/changed]
37
+ - id: router
38
+ name: acryl-router
39
+ - id: state
40
+ name: acryl-state
43
41
  - id: sandbox
44
42
  name: sandbox
45
43
  - id: extensions
@@ -53,6 +51,6 @@ spec:
53
51
  - id: routes
54
52
  name: routes
55
53
  - id: server
56
- name: server
54
+ name: acryl-node-server
57
55
  config:
58
56
  hostname: "{{parameters.hostname}}"
@@ -1,11 +1,12 @@
1
1
  import { join } from 'node:path'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { Context, type Fiber } from '@deepseek-ai/cordis'
4
+ import * as nodeServer from './plugins/acryl-node-server/src/index.ts'
5
+ import * as router from './plugins/acryl-router/src/index.ts'
6
+ import * as state from './plugins/acryl-state/src/index.ts'
4
7
  import { agent, memory, model, tools } from './plugins/agent/index.ts'
5
8
  import { builder } from './plugins/builder/index.ts'
6
- import { events } from './plugins/events/index.ts'
7
9
  import { extensions } from './plugins/extensions/index.ts'
8
- import { http } from './plugins/http/index.ts'
9
10
  import { type AppInstance, instance } from './plugins/instance/index.ts'
10
11
  import {
11
12
  blueprintsIn,
@@ -16,7 +17,6 @@ import {
16
17
  import { type OpenAIConfig, openai } from './plugins/openai/index.ts'
17
18
  import { sandboxPlugin } from './plugins/sandbox/index.ts'
18
19
  import { save } from './plugins/save/index.ts'
19
- import { server } from './plugins/server/index.ts'
20
20
  import { tasks } from './plugins/tasks/index.ts'
21
21
  import { routes } from './routes.ts'
22
22
 
@@ -27,14 +27,14 @@ export const registry = {
27
27
  tools,
28
28
  memory,
29
29
  agent,
30
- http,
31
- events,
30
+ 'acryl-router': router,
31
+ 'acryl-state': state,
32
32
  extensions,
33
33
  builder,
34
34
  save,
35
35
  sandbox: sandboxPlugin,
36
36
  routes,
37
- server,
37
+ 'acryl-node-server': nodeServer,
38
38
  }
39
39
 
40
40
  /** The manifest next to this app's source. */
@@ -85,12 +85,12 @@ export async function createApp(
85
85
  // The `approval` parameter in blend.yaml (the extensions row) turns this source into one that asks.
86
86
  extensions: { sources: [{ dir: extensionsDir, trust: 'install' }] },
87
87
  builder: { dir: extensionsDir },
88
- server: { port: chosen.port.start, scan: chosen.port.scan, hostname: chosen.host },
88
+ server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0, hostname: chosen.host },
89
89
  })),
90
90
  ]
91
91
  return {
92
92
  root,
93
- url: root.server.url,
93
+ url: root.nodeServer.url,
94
94
  async stop() {
95
95
  for (const fiber of [...fibers].reverse()) await fiber.dispose()
96
96
  },
@@ -1,62 +1,53 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
+ import { readJson } from './plugins/acryl-router/src/helpers/index.ts'
3
+ import type {} from './plugins/acryl-router/src/index.ts'
2
4
  import type {} from './plugins/agent/index.ts'
3
5
  import type {} from './plugins/extensions/index.ts'
4
- import type {} from './plugins/http/index.ts'
5
6
  import type {} from './plugins/tasks/index.ts'
6
7
 
7
8
  export const APP_NAME = '__APP_NAME__'
8
9
 
9
10
  /**
10
- * The app's own routes: `POST /ask` for the agent; `GET /extensions` and `GET /tasks` to observe what it did (every run is a durable
11
- * task owning one task per tool call); `POST /tasks/cancel`. Each route lives in a `ctx.effect`.
11
+ * The app's own routes on the router: `POST /ask` for the agent; `GET /extensions` and `GET /tasks` to observe what it did (every run is a
12
+ * durable task owning one task per tool call); `POST /tasks/cancel`. What changes is also streamed: `GET /api/state/tasks/events` and
13
+ * `GET /api/state/extensions/events`. A route registered from this plugin's context is removed when the plugin is disposed.
12
14
  */
13
15
  export const routes = {
14
16
  name: 'routes',
15
- inject: ['http', 'agent', 'extensions', 'tasks'],
17
+ inject: ['router', 'agent', 'extensions', 'tasks'],
16
18
  apply(ctx: Context) {
17
- ctx.effect(() => ctx.http.route('GET', '/', () => ({ body: { app: APP_NAME, ok: true } })))
18
- ctx.effect(() => ctx.http.route('GET', '/health', () => ({ body: { ok: true } })))
19
- ctx.effect(() =>
20
- ctx.http.route('GET', '/extensions', () => ({
21
- body: {
22
- installed: ctx.extensions.list(),
23
- notInstalled: ctx.extensions.pending(),
24
- waitingForApproval: ctx.extensions.approvals(),
25
- },
26
- })),
19
+ const { router } = ctx
20
+ router.get('/', (c) => c.json({ app: APP_NAME, ok: true }))
21
+ router.get('/health', (c) => c.json({ ok: true }))
22
+ router.get('/extensions', (c) =>
23
+ c.json({
24
+ installed: ctx.extensions.list(),
25
+ notInstalled: ctx.extensions.pending(),
26
+ waitingForApproval: ctx.extensions.approvals(),
27
+ }),
27
28
  )
28
29
  // The owner's decision on an install that is waiting (the `approval` parameter in blend.yaml).
29
30
  for (const [path, decide] of [
30
31
  ['/extensions/approve', (name: string) => ctx.extensions.approve(name)],
31
32
  ['/extensions/deny', (name: string) => ctx.extensions.deny(name)],
32
33
  ] as const) {
33
- ctx.effect(() =>
34
- ctx.http.route('POST', path, async ({ body }) => {
35
- const name = (body as { name?: unknown } | undefined)?.name
36
- if (typeof name !== 'string')
37
- return { status: 400, body: { error: 'name must be a string' } }
38
- return { body: await decide(name) }
39
- }),
40
- )
34
+ router.post(path, async (c) => {
35
+ const name = ((await readJson(c)) as { name?: unknown } | undefined)?.name
36
+ if (typeof name !== 'string') return c.json({ error: 'name must be a string' }, 400)
37
+ return c.json(await decide(name))
38
+ })
41
39
  }
42
- ctx.effect(() =>
43
- ctx.http.route('GET', '/tasks', () => ({ body: ctx.tasks.list().slice(0, 100) })),
44
- )
45
- ctx.effect(() =>
46
- ctx.http.route('POST', '/tasks/cancel', ({ body }) => {
47
- const id = (body as { id?: unknown } | undefined)?.id
48
- if (typeof id !== 'string' || !ctx.tasks.get(id))
49
- return { status: 404, body: { error: 'no such task' } }
50
- return { body: { cancelled: ctx.tasks.cancel(id) } }
51
- }),
52
- )
53
- ctx.effect(() =>
54
- ctx.http.route('POST', '/ask', async ({ body }) => {
55
- const question = (body as { question?: unknown } | undefined)?.question
56
- if (typeof question !== 'string')
57
- return { status: 400, body: { error: 'question must be a string' } }
58
- return { body: await ctx.agent.run(question) }
59
- }),
60
- )
40
+ router.get('/tasks', (c) => c.json(ctx.tasks.list().slice(0, 100)))
41
+ router.post('/tasks/cancel', async (c) => {
42
+ const id = ((await readJson(c)) as { id?: unknown } | undefined)?.id
43
+ if (typeof id !== 'string' || !ctx.tasks.get(id))
44
+ return c.json({ error: 'no such task' }, 404)
45
+ return c.json({ cancelled: ctx.tasks.cancel(id) })
46
+ })
47
+ router.post('/ask', async (c) => {
48
+ const question = ((await readJson(c)) as { question?: unknown } | undefined)?.question
49
+ if (typeof question !== 'string') return c.json({ error: 'question must be a string' }, 400)
50
+ return c.json(await ctx.agent.run(question))
51
+ })
61
52
  },
62
53
  }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "plugins": [
3
3
  "instance",
4
- "http",
5
- "events",
6
- "server",
4
+ "acryl-router",
5
+ "acryl-state",
6
+ "acryl-node-server",
7
7
  "tasks",
8
8
  "agent",
9
9
  "openai",
@@ -134,51 +134,59 @@ test('the agent builds a sandboxed tool, calls it, and the sandbox refuses a too
134
134
  }
135
135
  })
136
136
 
137
- test('GET /events streams what happens: the run as a task, and the install as an extension change', async () => {
137
+ test('what happens is streamed from the state cells: the run as a task, and the install as an extension', async () => {
138
138
  const project = await mkdtemp(join(tmpdir(), 'events-project-'))
139
139
  const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
140
140
  const app = await createApp(chosen)
141
141
  const controller = new AbortController()
142
- try {
143
- const stream = await fetch(`${app.url}/events`, { signal: controller.signal })
142
+ /** Follow one server-sent stream: its text so far. */
143
+ const follow = async (path: string) => {
144
+ const stream = await fetch(`${app.url}${path}`, { signal: controller.signal })
145
+ assert.equal(stream.status, 200, path)
144
146
  const reader = stream.body!.getReader()
145
147
  const decoder = new TextDecoder()
146
- let text = ''
147
- const seen = (what: RegExp) => what.test(text)
148
+ const seen = { text: '' }
148
149
  const pump = (async () => {
149
150
  for (;;) {
150
151
  const { value, done } = await reader.read()
151
152
  if (done) return
152
- text += decoder.decode(value, { stream: true })
153
+ seen.text += decoder.decode(value, { stream: true })
153
154
  }
154
155
  })().catch(() => undefined)
156
+ return { seen, pump }
157
+ }
158
+ try {
159
+ const tasksStream = await follow('/api/state/tasks/events')
160
+ const extensionsStream = await follow('/api/state/extensions/events')
155
161
  await fetch(`${app.url}/ask`, {
156
162
  method: 'POST',
157
163
  headers: { 'content-type': 'application/json' },
158
164
  body: JSON.stringify({ question: 'create route hello /hello Hello' }),
159
165
  })
166
+ const arrived = (stream: { text: string }, what: RegExp) => what.test(stream.text)
160
167
  for (
161
168
  let waited = 0;
162
- waited < 3000 && !seen(/event: extensions\/changed\ndata: [^\n]*"installed"/);
169
+ waited < 3000 && !arrived(extensionsStream.seen, /"name":"hello"[^\n]*"state":"active"/);
163
170
  waited += 25
164
171
  ) {
165
172
  await new Promise((done) => setTimeout(done, 25))
166
173
  }
167
- assert.ok(seen(/event: connected/), 'connected')
168
174
  assert.ok(
169
- seen(/event: tasks\/changed\ndata: [^\n]*"kind":"agent-run"[^\n]*"status":"running"/),
170
- 'the run starts as a task',
175
+ arrived(tasksStream.seen, /event: value\ndata: \[\]/),
176
+ 'the stream starts with the current value',
171
177
  )
172
178
  assert.ok(
173
- seen(/event: tasks\/changed\ndata: [^\n]*"kind":"tool:install_plugin"/),
174
- 'the install is a task',
179
+ arrived(tasksStream.seen, /"kind":"agent-run"[^\n]*"status":"running"/) ||
180
+ arrived(tasksStream.seen, /"kind":"agent-run"/),
181
+ 'the run is a task',
175
182
  )
183
+ assert.ok(arrived(tasksStream.seen, /"kind":"tool:install_plugin"/), 'the install is a task')
176
184
  assert.ok(
177
- seen(/event: extensions\/changed\ndata: \{"args":\["hello","installed"\]\}/),
185
+ arrived(extensionsStream.seen, /"name":"hello"[^\n]*"state":"active"/),
178
186
  'the extension is reported',
179
187
  )
180
188
  controller.abort()
181
- await pump
189
+ await Promise.all([tasksStream.pump, extensionsStream.pump])
182
190
  } finally {
183
191
  await app.stop()
184
192
  await rm(project, { recursive: true, force: true })
@@ -11,11 +11,10 @@ import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
11
11
  /** A plugin no scripted command knows: the model writes it from scratch. */
12
12
  const COUNTER = `import type { Context } from '@deepseek-ai/cordis'
13
13
  export const name = 'counter'
14
- export const inject = ['http']
14
+ export const inject = ['router']
15
15
  export function apply(ctx: Context) {
16
16
  let count = 0
17
- const http = ctx.get('http') as { route(m: string, p: string, h: () => { body: unknown }): () => void }
18
- ctx.effect(() => http.route('GET', '/count', () => ({ body: { count: ++count } })))
17
+ ctx.router.get('/count', (c) => c.json({ count: ++count }))
19
18
  }
20
19
  `
21
20
 
@@ -13,11 +13,11 @@ spec:
13
13
  # The address the server listens on.
14
14
  hostname: { type: string, default: 127.0.0.1 }
15
15
  rows:
16
- - id: http
17
- name: http
16
+ - id: router
17
+ name: acryl-router
18
18
  - id: routes
19
19
  name: routes
20
20
  - id: server
21
- name: server
21
+ name: acryl-node-server
22
22
  config:
23
23
  hostname: "{{parameters.hostname}}"
@@ -1,7 +1,8 @@
1
1
  import { join } from 'node:path'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { Context, type Fiber } from '@deepseek-ai/cordis'
4
- import { http } from './plugins/http/index.ts'
4
+ import * as nodeServer from './plugins/acryl-node-server/src/index.ts'
5
+ import * as router from './plugins/acryl-router/src/index.ts'
5
6
  import { type AppInstance, instance } from './plugins/instance/index.ts'
6
7
  import {
7
8
  blueprintsIn,
@@ -9,11 +10,10 @@ import {
9
10
  loadManifest,
10
11
  type ParameterValue,
11
12
  } from './plugins/manifest/index.ts'
12
- import { server } from './plugins/server/index.ts'
13
13
  import { routes } from './routes.ts'
14
14
 
15
15
  /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
16
- export const registry = { http, routes, server }
16
+ export const registry = { 'acryl-router': router, 'acryl-node-server': nodeServer, routes }
17
17
 
18
18
  /** The manifest next to this app's source. */
19
19
  export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
@@ -48,12 +48,12 @@ export async function createApp(
48
48
  const fibers: Fiber[] = [
49
49
  first,
50
50
  ...(await compose(root, manifest, registry, {
51
- server: { port: chosen.port.start, scan: chosen.port.scan },
51
+ server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
52
52
  })),
53
53
  ]
54
54
  return {
55
55
  root,
56
- url: root.server.url,
56
+ url: root.nodeServer.url,
57
57
  async stop() {
58
58
  for (const fiber of [...fibers].reverse()) await fiber.dispose()
59
59
  },