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.
- package/dist/index.d.ts +17 -16
- package/dist/index.js +39 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/plugins/acryl-inspect/LICENSE +21 -0
- package/plugins/acryl-inspect/plugin.json +11 -0
- package/plugins/acryl-inspect/src/index.ts +3 -0
- package/plugins/acryl-inspect/src/report.ts +123 -0
- package/plugins/acryl-inspect/src/service.ts +66 -0
- package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
- package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
- package/plugins/acryl-inspect/upstream.json +18 -0
- package/plugins/acryl-node-server/LICENSE +21 -0
- package/plugins/acryl-node-server/plugin.json +12 -0
- package/plugins/acryl-node-server/src/index.ts +94 -0
- package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
- package/plugins/acryl-node-server/upstream.json +18 -0
- package/plugins/acryl-router/LICENSE +21 -0
- package/plugins/acryl-router/plugin.json +12 -0
- package/plugins/acryl-router/src/compose.ts +21 -0
- package/plugins/acryl-router/src/context.ts +59 -0
- package/plugins/acryl-router/src/helpers/index.ts +3 -0
- package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
- package/plugins/acryl-router/src/helpers/sse.ts +83 -0
- package/plugins/acryl-router/src/helpers/validate.ts +44 -0
- package/plugins/acryl-router/src/http-error.ts +21 -0
- package/plugins/acryl-router/src/index.ts +6 -0
- package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
- package/plugins/acryl-router/src/middleware/cors.ts +46 -0
- package/plugins/acryl-router/src/middleware/index.ts +6 -0
- package/plugins/acryl-router/src/middleware/logger.ts +21 -0
- package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
- package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
- package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
- package/plugins/acryl-router/src/pattern.ts +89 -0
- package/plugins/acryl-router/src/registrar.ts +90 -0
- package/plugins/acryl-router/src/service.ts +164 -0
- package/plugins/acryl-router/src/table.ts +156 -0
- package/plugins/acryl-router/src/types.ts +73 -0
- package/plugins/acryl-router/src/util.ts +23 -0
- package/plugins/acryl-router/tests/api.spec.ts +223 -0
- package/plugins/acryl-router/tests/conformance.ts +170 -0
- package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
- package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
- package/plugins/acryl-router/tests/loader.spec.ts +71 -0
- package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
- package/plugins/acryl-router/tests/service.spec.ts +211 -0
- package/plugins/acryl-router/tests/table.spec.ts +47 -0
- package/plugins/acryl-router/upstream.json +20 -0
- package/plugins/acryl-router-http/LICENSE +21 -0
- package/plugins/acryl-router-http/plugin.json +11 -0
- package/plugins/acryl-router-http/src/bridge.ts +52 -0
- package/plugins/acryl-router-http/src/index.ts +63 -0
- package/plugins/acryl-router-http/src/serve.ts +24 -0
- package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
- package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
- package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
- package/plugins/acryl-router-http/upstream.json +22 -0
- package/plugins/acryl-state/LICENSE +21 -0
- package/plugins/acryl-state/plugin.json +12 -0
- package/plugins/acryl-state/src/cell.ts +67 -0
- package/plugins/acryl-state/src/index.ts +3 -0
- package/plugins/acryl-state/src/react.ts +11 -0
- package/plugins/acryl-state/src/service.ts +101 -0
- package/plugins/acryl-state/tests/cell.spec.ts +71 -0
- package/plugins/acryl-state/tests/react.spec.ts +39 -0
- package/plugins/acryl-state/tests/service.spec.ts +132 -0
- package/plugins/acryl-state/upstream.json +20 -0
- package/plugins/builder/script.ts +8 -9
- package/plugins/extensions/index.ts +18 -1
- package/plugins/openai/prompt.ts +9 -5
- package/plugins/tasks/index.ts +14 -0
- package/plugins/tsconfig.json +9 -1
- package/templates/agent/blend.yaml +5 -7
- package/templates/agent/src/app.ts +8 -8
- package/templates/agent/src/routes.ts +32 -41
- package/templates/agent/template.json +3 -3
- package/templates/agent/test/builder.test.ts +22 -14
- package/templates/agent/test/model.test.ts +2 -3
- package/templates/{app → blank}/blend.yaml +3 -3
- package/templates/{app → blank}/src/app.ts +5 -5
- package/templates/blank/src/routes.ts +14 -0
- package/templates/blank/template.json +1 -0
- package/templates/{app → blank}/test/app.test.ts +1 -1
- package/templates/todo/README.md +27 -0
- package/templates/todo/blend.yaml +25 -0
- package/templates/todo/gitignore +2 -0
- package/templates/todo/package.json +23 -0
- package/templates/todo/src/app.ts +71 -0
- package/templates/todo/src/main.ts +19 -0
- package/templates/todo/src/page.ts +28 -0
- package/templates/todo/src/todo.ts +96 -0
- package/templates/todo/template.json +1 -0
- package/templates/todo/test/todo.test.ts +145 -0
- package/templates/todo/tsconfig.json +19 -0
- package/templates/todo/upstream.json +12 -0
- package/plugins/events/index.ts +0 -42
- package/plugins/events/plugin.json +0 -9
- package/plugins/http/index.ts +0 -64
- package/plugins/http/plugin.json +0 -10
- package/plugins/server/index.ts +0 -138
- package/plugins/server/plugin.json +0 -10
- package/templates/app/src/routes.ts +0 -14
- package/templates/app/template.json +0 -1
- /package/templates/{app → blank}/README.md +0 -0
- /package/templates/{app → blank}/gitignore +0 -0
- /package/templates/{app → blank}/package.json +0 -0
- /package/templates/{app → blank}/src/main.ts +0 -0
- /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 = ['
|
|
24
|
+
export const inject = ['router']
|
|
25
25
|
|
|
26
|
-
interface
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
ctx
|
|
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('
|
|
38
|
-
if (reply.
|
|
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.
|
|
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 }
|
package/plugins/openai/prompt.ts
CHANGED
|
@@ -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 = ['
|
|
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
|
-
|
|
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
|
|
21
|
-
if (
|
|
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": {...} } ] }
|
package/plugins/tasks/index.ts
CHANGED
|
@@ -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. */
|
package/plugins/tsconfig.json
CHANGED
|
@@ -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:
|
|
38
|
-
name:
|
|
39
|
-
- id:
|
|
40
|
-
name:
|
|
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
|
-
|
|
31
|
-
|
|
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.
|
|
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
|
|
11
|
-
* task owning one task per tool call); `POST /tasks/cancel`.
|
|
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: ['
|
|
17
|
+
inject: ['router', 'agent', 'extensions', 'tasks'],
|
|
16
18
|
apply(ctx: Context) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
|
|
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
|
}
|
|
@@ -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('
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
170
|
-
'the
|
|
175
|
+
arrived(tasksStream.seen, /event: value\ndata: \[\]/),
|
|
176
|
+
'the stream starts with the current value',
|
|
171
177
|
)
|
|
172
178
|
assert.ok(
|
|
173
|
-
seen
|
|
174
|
-
|
|
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
|
|
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 = ['
|
|
14
|
+
export const inject = ['router']
|
|
15
15
|
export function apply(ctx: Context) {
|
|
16
16
|
let count = 0
|
|
17
|
-
|
|
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:
|
|
17
|
-
name:
|
|
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
|
|
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 = {
|
|
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.
|
|
56
|
+
url: root.nodeServer.url,
|
|
57
57
|
async stop() {
|
|
58
58
|
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
59
59
|
},
|