acrylblends 0.7.8 → 0.8.1
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 +13 -5
- package/dist/index.js +72 -13
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +2 -2
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/getting-started/basic.md +1 -2
- package/docs/framework/getting-started/desktop.md +1 -1
- package/docs/framework/guides/bring-your-own-tool.md +216 -0
- package/docs/framework/guides/create-acrylblends.md +5 -4
- package/docs/framework/guides/data.md +2 -1
- package/docs/framework/upstream.json +3 -3
- package/lib/node-server.js +1 -0
- package/package.json +1 -1
- package/plugins/acryl-db/plugin.json +6 -1
- package/plugins/acryl-db/src/conformance.ts +224 -0
- package/plugins/acryl-db/upstream.json +1 -1
- package/plugins/acryl-db-sqlite/plugin.json +4 -1
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-door/LICENSE +47 -0
- package/plugins/acryl-door/plugin.json +14 -0
- package/plugins/acryl-door/src/cli/acryl.ts +207 -0
- package/plugins/acryl-door/src/host/api.ts +9 -0
- package/plugins/acryl-door/src/host/audit.ts +64 -0
- package/plugins/acryl-door/src/host/channel.ts +142 -0
- package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
- package/plugins/acryl-door/src/host/door-file.ts +94 -0
- package/plugins/acryl-door/src/host/door.ts +195 -0
- package/plugins/acryl-door/src/host/http.ts +80 -0
- package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
- package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
- package/plugins/acryl-door/src/host/page-routes.ts +65 -0
- package/plugins/acryl-door/src/host/shim.ts +65 -0
- package/plugins/acryl-door/src/host/verbs.ts +109 -0
- package/plugins/acryl-door/src/index.ts +133 -0
- package/plugins/acryl-door/src/page/api.ts +9 -0
- package/plugins/acryl-door/src/page/channel.ts +135 -0
- package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
- package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
- package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
- package/plugins/acryl-door/src/page/index.ts +40 -0
- package/plugins/acryl-door/src/page/indicator.ts +72 -0
- package/plugins/acryl-door/src/page/main.ts +7 -0
- package/plugins/acryl-door/src/page/protocol.ts +213 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
- package/plugins/acryl-door/src/page/user-input.ts +24 -0
- package/plugins/acryl-door/upstream.json +28 -0
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/plugin.json +6 -1
- package/plugins/acryl-kv/src/conformance.ts +226 -0
- package/plugins/acryl-kv/upstream.json +1 -1
- package/plugins/acryl-kv-db/plugin.json +4 -1
- package/plugins/acryl-kv-db/upstream.json +1 -1
- package/plugins/acryl-kv-json/plugin.json +4 -1
- package/plugins/acryl-kv-json/upstream.json +1 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/src/service.ts +3 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/agent-panel/client/main.ts +159 -0
- package/plugins/agent-panel/index.ts +232 -0
- package/plugins/agent-panel/plugin.json +21 -0
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/resolve.ts +16 -2
- package/plugins/tsconfig.json +6 -0
- package/shared/DEFAULTS.md +11 -6
- package/shared/SKILL.md +1 -1
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/src/app.ts +18 -2
- package/templates/agent/src/routes.ts +14 -1
- package/templates/agent/template.json +2 -1
- package/templates/agent/test/agent.test.ts +10 -4
- package/templates/agent/test/app.test.ts +29 -8
- package/templates/agent/test/builder.test.ts +34 -23
- package/templates/agent/test/model.test.ts +24 -19
- package/templates/blank/src/app.ts +25 -5
- package/templates/blank/test/app.test.ts +28 -9
- package/templates/chat/AGENTS.md +16 -0
- package/templates/chat/blend.yaml +32 -0
- package/templates/chat/gitignore +6 -0
- package/templates/chat/migrations/001-chat.sql +12 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/src/app.ts +107 -0
- package/templates/chat/src/chat-app.ts +101 -0
- package/templates/chat/src/client/chat.tsx +115 -0
- package/templates/chat/src/client/tsconfig.json +26 -0
- package/templates/chat/src/db.ts +43 -0
- package/templates/chat/src/door.ts +7 -0
- package/templates/chat/src/llm.ts +42 -0
- package/templates/chat/src/main.ts +28 -0
- package/templates/chat/src/page.tsx +40 -0
- package/templates/chat/src/routes.ts +15 -0
- package/templates/chat/src/runtime.ts +17 -0
- package/templates/chat/template.json +12 -0
- package/templates/chat/test/chat.test.ts +198 -0
- package/templates/chat/test/server.test.ts +78 -0
- package/templates/chat/tsconfig.json +20 -0
- package/templates/nodejs/AGENTS.md +9 -0
- package/templates/nodejs/blend.yaml +29 -0
- package/templates/nodejs/gitignore +3 -0
- package/templates/nodejs/package.json +30 -0
- package/templates/nodejs/src/app.ts +95 -0
- package/templates/nodejs/src/client/counter.tsx +12 -0
- package/templates/nodejs/src/client/tsconfig.json +26 -0
- package/templates/nodejs/src/door.ts +7 -0
- package/templates/nodejs/src/main.ts +25 -0
- package/templates/nodejs/src/page.tsx +29 -0
- package/templates/nodejs/src/routes.ts +40 -0
- package/templates/nodejs/template.json +11 -0
- package/templates/nodejs/test/app.test.ts +78 -0
- package/templates/nodejs/test/server.test.ts +83 -0
- package/templates/nodejs/tsconfig.json +20 -0
- package/templates/notes/AGENTS.md +26 -0
- package/templates/notes/blend.yaml +32 -0
- package/templates/notes/gitignore +6 -0
- package/templates/notes/migrations/001-notes.sql +18 -0
- package/templates/notes/package.json +33 -0
- package/templates/notes/src/app.ts +107 -0
- package/templates/notes/src/auth.ts +57 -0
- package/templates/notes/src/client/counter.tsx +21 -0
- package/templates/notes/src/client/tsconfig.json +26 -0
- package/templates/notes/src/db.ts +50 -0
- package/templates/notes/src/door.ts +7 -0
- package/templates/notes/src/main.ts +28 -0
- package/templates/notes/src/notes-app.ts +205 -0
- package/templates/notes/src/routes.ts +14 -0
- package/templates/notes/src/runtime.ts +17 -0
- package/templates/notes/src/schema.ts +38 -0
- package/templates/notes/src/views.tsx +136 -0
- package/templates/notes/template.json +12 -0
- package/templates/notes/test/notes.test.ts +280 -0
- package/templates/notes/test/server.test.ts +78 -0
- package/templates/notes/tsconfig.json +20 -0
- package/templates/todo/src/app.ts +27 -7
- package/templates/todo/test/todo.test.ts +47 -22
- package/templates/todo/upstream.json +1 -1
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
2
3
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
-
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { test } from 'node:test'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
|
|
8
8
|
import { type App, createApp } from '../src/app.ts'
|
|
9
9
|
|
|
10
|
+
/** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
|
|
11
|
+
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
12
|
+
mkdirSync(scratch, { recursive: true })
|
|
13
|
+
|
|
10
14
|
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
11
15
|
|
|
12
16
|
async function withApp(run: (app: App) => Promise<void>) {
|
|
13
|
-
const home = await mkdtemp(join(
|
|
14
|
-
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0)
|
|
17
|
+
const home = await mkdtemp(join(scratch, 'agent-test-'))
|
|
18
|
+
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
|
|
19
|
+
listen: false,
|
|
20
|
+
})
|
|
15
21
|
try {
|
|
16
22
|
await run(app)
|
|
17
23
|
} finally {
|
|
@@ -21,7 +27,7 @@ async function withApp(run: (app: App) => Promise<void>) {
|
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
const ask = (app: App, body: unknown) =>
|
|
24
|
-
|
|
30
|
+
app.root.router.request(`/ask`, {
|
|
25
31
|
method: 'POST',
|
|
26
32
|
headers: { 'content-type': 'application/json' },
|
|
27
33
|
body: JSON.stringify(body),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
2
3
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
-
import {
|
|
4
|
+
import { createServer } from 'node:net'
|
|
4
5
|
import { join } from 'node:path'
|
|
5
6
|
import { test } from 'node:test'
|
|
6
7
|
import { fileURLToPath } from 'node:url'
|
|
@@ -8,12 +9,18 @@ import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
|
|
|
8
9
|
import { type App, createApp } from '../src/app.ts'
|
|
9
10
|
import { APP_NAME } from '../src/routes.ts'
|
|
10
11
|
|
|
12
|
+
/** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
|
|
13
|
+
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
14
|
+
mkdirSync(scratch, { recursive: true })
|
|
15
|
+
|
|
11
16
|
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
12
17
|
|
|
13
18
|
/** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. */
|
|
14
19
|
async function withApp(run: (app: App) => Promise<void>) {
|
|
15
|
-
const home = await mkdtemp(join(
|
|
16
|
-
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0)
|
|
20
|
+
const home = await mkdtemp(join(scratch, 'app-test-'))
|
|
21
|
+
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
|
|
22
|
+
listen: false,
|
|
23
|
+
})
|
|
17
24
|
try {
|
|
18
25
|
await run(app)
|
|
19
26
|
} finally {
|
|
@@ -24,21 +31,35 @@ async function withApp(run: (app: App) => Promise<void>) {
|
|
|
24
31
|
|
|
25
32
|
test('serves the index route', () =>
|
|
26
33
|
withApp(async (app) => {
|
|
27
|
-
const response = await
|
|
34
|
+
const response = await app.root.router.request(`/`)
|
|
28
35
|
assert.equal(response.status, 200)
|
|
29
36
|
assert.deepEqual(await response.json(), { app: APP_NAME, ok: true })
|
|
30
37
|
}))
|
|
31
38
|
|
|
32
39
|
test('answers health checks and 404 for unknown paths', () =>
|
|
33
40
|
withApp(async (app) => {
|
|
34
|
-
assert.equal((await
|
|
35
|
-
const missing = await
|
|
41
|
+
assert.equal((await app.root.router.request(`/health`)).status, 200)
|
|
42
|
+
const missing = await app.root.router.request(`/nope`)
|
|
36
43
|
assert.equal(missing.status, 404)
|
|
37
44
|
await missing.body?.cancel()
|
|
38
45
|
}))
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
/** A sandbox may forbid listening on a port (Codex's workspace-write does); the one test that starts the real server is skipped there, and says so. */
|
|
48
|
+
const canListen = await new Promise<boolean>((done) => {
|
|
49
|
+
const probe = createServer()
|
|
50
|
+
probe.once('error', () => done(false))
|
|
51
|
+
probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
|
|
52
|
+
})
|
|
53
|
+
const skip = canListen ? false : 'this environment does not allow listening on a port'
|
|
54
|
+
if (!canListen)
|
|
55
|
+
console.log(
|
|
56
|
+
`skipped: the test that starts the real server (${skip}); every other test ran in process`,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
test('stops listening when stopped', { skip }, async () => {
|
|
60
|
+
const home = await mkdtemp(join(scratch, 'app-test-'))
|
|
61
|
+
// The door is left out (ACRYL_DOOR=off): one process at a time serves a folder's door.
|
|
62
|
+
process.env.ACRYL_DOOR = 'off'
|
|
42
63
|
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
|
|
43
64
|
await app.stop()
|
|
44
65
|
await assert.rejects(fetch(`${app.url}/health`))
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
2
3
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
-
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { test } from 'node:test'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
6
7
|
import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
|
|
7
8
|
import { createApp } from '../src/app.ts'
|
|
8
9
|
|
|
10
|
+
/** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
|
|
11
|
+
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
12
|
+
mkdirSync(scratch, { recursive: true })
|
|
13
|
+
|
|
9
14
|
/** The agent extends the running app through POST /ask; a restart keeps what it built. */
|
|
10
15
|
test('the agent builds, updates, protects and removes a plugin in the running app, and a restart keeps it', async () => {
|
|
11
|
-
const project = await mkdtemp(join(
|
|
16
|
+
const project = await mkdtemp(join(scratch, 'builder-project-'))
|
|
12
17
|
const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
|
|
13
|
-
let app = await createApp(chosen)
|
|
18
|
+
let app = await createApp(chosen, { listen: false })
|
|
14
19
|
const ask = async (question: string) => {
|
|
15
|
-
const response = await
|
|
20
|
+
const response = await app.root.router.request(`/ask`, {
|
|
16
21
|
method: 'POST',
|
|
17
22
|
headers: { 'content-type': 'application/json' },
|
|
18
23
|
body: JSON.stringify({ question }),
|
|
@@ -20,7 +25,7 @@ test('the agent builds, updates, protects and removes a plugin in the running ap
|
|
|
20
25
|
return ((await response.json()) as { output: string }).output
|
|
21
26
|
}
|
|
22
27
|
const hello = async () => {
|
|
23
|
-
const response = await
|
|
28
|
+
const response = await app.root.router.request(`/hello`)
|
|
24
29
|
return response.status === 200
|
|
25
30
|
? ((await response.json()) as { text: string }).text
|
|
26
31
|
: response.status
|
|
@@ -36,14 +41,14 @@ test('the agent builds, updates, protects and removes a plugin in the running ap
|
|
|
36
41
|
// The broken version stays on disk, so put the working one back before restarting.
|
|
37
42
|
await ask('update route hello /hello Bonjour')
|
|
38
43
|
await app.stop()
|
|
39
|
-
app = await createApp(chosen)
|
|
44
|
+
app = await createApp(chosen, { listen: false })
|
|
40
45
|
assert.equal(await hello(), 'Bonjour')
|
|
41
46
|
|
|
42
47
|
assert.equal(await ask('remove plugin hello'), 'hello: done.')
|
|
43
48
|
assert.equal(await hello(), 404)
|
|
44
49
|
|
|
45
50
|
// Every run is a durable task that owns its tool calls, and the records survived the restart.
|
|
46
|
-
const records = (await (await
|
|
51
|
+
const records = (await (await app.root.router.request(`/tasks`)).json()) as Array<{
|
|
47
52
|
kind: string
|
|
48
53
|
status: string
|
|
49
54
|
parent?: string
|
|
@@ -63,12 +68,12 @@ test('the agent builds, updates, protects and removes a plugin in the running ap
|
|
|
63
68
|
})
|
|
64
69
|
|
|
65
70
|
test('with approval on, an install waits for the owner; approving it makes it live', async () => {
|
|
66
|
-
const project = await mkdtemp(join(
|
|
71
|
+
const project = await mkdtemp(join(scratch, 'approval-project-'))
|
|
67
72
|
const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
|
|
68
|
-
const app = await createApp(chosen, { values: { approval: true } })
|
|
73
|
+
const app = await createApp(chosen, { values: { approval: true }, listen: false })
|
|
69
74
|
const post = async (path: string, body: unknown) =>
|
|
70
75
|
(await (
|
|
71
|
-
await
|
|
76
|
+
await app.root.router.request(`${path}`, {
|
|
72
77
|
method: 'POST',
|
|
73
78
|
headers: { 'content-type': 'application/json' },
|
|
74
79
|
body: JSON.stringify(body),
|
|
@@ -79,8 +84,8 @@ test('with approval on, an install waits for the owner; approving it makes it li
|
|
|
79
84
|
(await post('/ask', { question: 'create route hello /hello Hello' })).output,
|
|
80
85
|
"hello: waiting for the owner's approval.",
|
|
81
86
|
)
|
|
82
|
-
assert.equal((await
|
|
83
|
-
const listed = (await (await
|
|
87
|
+
assert.equal((await app.root.router.request(`/hello`)).status, 404)
|
|
88
|
+
const listed = (await (await app.root.router.request(`/extensions`)).json()) as {
|
|
84
89
|
waitingForApproval: Array<{ name: string }>
|
|
85
90
|
}
|
|
86
91
|
assert.deepEqual(
|
|
@@ -91,8 +96,10 @@ test('with approval on, an install waits for the owner; approving it makes it li
|
|
|
91
96
|
((await post('/extensions/approve', { name: 'hello' })) as { state?: string }).state,
|
|
92
97
|
'active',
|
|
93
98
|
)
|
|
94
|
-
assert.deepEqual(await (await
|
|
95
|
-
const tasks = (await (await
|
|
99
|
+
assert.deepEqual(await (await app.root.router.request(`/hello`)).json(), { text: 'Hello' })
|
|
100
|
+
const tasks = (await (await app.root.router.request(`/tasks`)).json()) as Array<{
|
|
101
|
+
manifest?: string
|
|
102
|
+
}>
|
|
96
103
|
assert.ok(
|
|
97
104
|
tasks.length > 0 && tasks.every((task) => /^sha256:[0-9a-f]{64}$/.test(task.manifest ?? '')),
|
|
98
105
|
)
|
|
@@ -103,13 +110,13 @@ test('with approval on, an install waits for the owner; approving it makes it li
|
|
|
103
110
|
})
|
|
104
111
|
|
|
105
112
|
test('the agent builds a sandboxed tool, calls it, and the sandbox refuses a tool that reads outside its folder', async () => {
|
|
106
|
-
const project = await mkdtemp(join(
|
|
113
|
+
const project = await mkdtemp(join(scratch, 'tool-project-'))
|
|
107
114
|
const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
|
|
108
|
-
const app = await createApp(chosen)
|
|
115
|
+
const app = await createApp(chosen, { listen: false })
|
|
109
116
|
const ask = async (question: string) =>
|
|
110
117
|
(
|
|
111
118
|
(await (
|
|
112
|
-
await
|
|
119
|
+
await app.root.router.request(`/ask`, {
|
|
113
120
|
method: 'POST',
|
|
114
121
|
headers: { 'content-type': 'application/json' },
|
|
115
122
|
body: JSON.stringify({ question }),
|
|
@@ -121,7 +128,7 @@ test('the agent builds a sandboxed tool, calls it, and the sandbox refuses a too
|
|
|
121
128
|
assert.equal(await ask('run tool triple 14'), '{"n":42}')
|
|
122
129
|
const refused = await ask('spy tool snoop')
|
|
123
130
|
assert.match(refused, /^Refused: its check failed: denied:/)
|
|
124
|
-
const listed = (await (await
|
|
131
|
+
const listed = (await (await app.root.router.request(`/extensions`)).json()) as {
|
|
125
132
|
installed: Array<{ name: string }>
|
|
126
133
|
}
|
|
127
134
|
assert.deepEqual(
|
|
@@ -135,13 +142,13 @@ test('the agent builds a sandboxed tool, calls it, and the sandbox refuses a too
|
|
|
135
142
|
})
|
|
136
143
|
|
|
137
144
|
test('what happens is streamed from the state cells: the run as a task, and the install as an extension', async () => {
|
|
138
|
-
const project = await mkdtemp(join(
|
|
145
|
+
const project = await mkdtemp(join(scratch, 'events-project-'))
|
|
139
146
|
const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
|
|
140
|
-
const app = await createApp(chosen)
|
|
147
|
+
const app = await createApp(chosen, { listen: false })
|
|
141
148
|
const controller = new AbortController()
|
|
142
149
|
/** Follow one server-sent stream: its text so far. */
|
|
143
150
|
const follow = async (path: string) => {
|
|
144
|
-
const stream = await
|
|
151
|
+
const stream = await app.root.router.request(`${path}`, { signal: controller.signal })
|
|
145
152
|
assert.equal(stream.status, 200, path)
|
|
146
153
|
const reader = stream.body!.getReader()
|
|
147
154
|
const decoder = new TextDecoder()
|
|
@@ -153,12 +160,12 @@ test('what happens is streamed from the state cells: the run as a task, and the
|
|
|
153
160
|
seen.text += decoder.decode(value, { stream: true })
|
|
154
161
|
}
|
|
155
162
|
})().catch(() => undefined)
|
|
156
|
-
return { seen, pump }
|
|
163
|
+
return { seen, pump, reader }
|
|
157
164
|
}
|
|
158
165
|
try {
|
|
159
166
|
const tasksStream = await follow('/api/state/tasks/events')
|
|
160
167
|
const extensionsStream = await follow('/api/state/extensions/events')
|
|
161
|
-
await
|
|
168
|
+
await app.root.router.request(`/ask`, {
|
|
162
169
|
method: 'POST',
|
|
163
170
|
headers: { 'content-type': 'application/json' },
|
|
164
171
|
body: JSON.stringify({ question: 'create route hello /hello Hello' }),
|
|
@@ -185,7 +192,11 @@ test('what happens is streamed from the state cells: the run as a task, and the
|
|
|
185
192
|
arrived(extensionsStream.seen, /"name":"hello"[^\n]*"state":"active"/),
|
|
186
193
|
'the extension is reported',
|
|
187
194
|
)
|
|
195
|
+
// In process there is no socket to close: the readers are cancelled, which ends the streams and lets the pumps finish.
|
|
188
196
|
controller.abort()
|
|
197
|
+
await Promise.all([tasksStream.reader.cancel(), extensionsStream.reader.cancel()]).catch(
|
|
198
|
+
() => undefined,
|
|
199
|
+
)
|
|
189
200
|
await Promise.all([tasksStream.pump, extensionsStream.pump])
|
|
190
201
|
} finally {
|
|
191
202
|
await app.stop()
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
2
3
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
-
import { createServer } from 'node:http'
|
|
4
|
-
import type { AddressInfo } from 'node:net'
|
|
5
|
-
import { tmpdir } from 'node:os'
|
|
6
4
|
import { join } from 'node:path'
|
|
7
5
|
import { test } from 'node:test'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
8
7
|
import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
|
|
9
8
|
import { createApp } from '../src/app.ts'
|
|
10
9
|
|
|
10
|
+
/** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
|
|
11
|
+
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
12
|
+
mkdirSync(scratch, { recursive: true })
|
|
13
|
+
|
|
11
14
|
/** A plugin no scripted command knows: the model writes it from scratch. */
|
|
12
15
|
const COUNTER = `import type { Context } from '@deepseek-ai/cordis'
|
|
13
16
|
import { Acryl } from 'acrylblends'
|
|
17
|
+
|
|
14
18
|
export const name = 'counter'
|
|
15
19
|
export const inject = ['router']
|
|
16
20
|
export function apply(ctx: Context) {
|
|
@@ -45,42 +49,43 @@ async function modelApi() {
|
|
|
45
49
|
},
|
|
46
50
|
{ content: 'I built a counter: GET /count.' },
|
|
47
51
|
]
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
})
|
|
57
|
-
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done))
|
|
52
|
+
// The model API is answered in process: the global fetch is wrapped for the model's address, so no port is needed.
|
|
53
|
+
const realFetch = globalThis.fetch
|
|
54
|
+
const url = 'http://model.test/v1'
|
|
55
|
+
globalThis.fetch = async (input, init) => {
|
|
56
|
+
const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
|
57
|
+
if (!target.startsWith(url)) return realFetch(input, init)
|
|
58
|
+
return Response.json({ choices: [{ message: replies.shift() ?? { content: 'no more' } }] })
|
|
59
|
+
}
|
|
58
60
|
return {
|
|
59
|
-
url
|
|
60
|
-
close:
|
|
61
|
+
url,
|
|
62
|
+
close: async () => {
|
|
63
|
+
globalThis.fetch = realFetch
|
|
64
|
+
},
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
test('with a real model, the agent writes a new Cordis plugin and the running app serves it', async () => {
|
|
65
69
|
const api = await modelApi()
|
|
66
|
-
const project = await mkdtemp(join(
|
|
70
|
+
const project = await mkdtemp(join(scratch, 'model-project-'))
|
|
67
71
|
const app = await createApp(
|
|
68
72
|
withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0),
|
|
69
73
|
{
|
|
70
74
|
model: { baseUrl: api.url, model: 'test' },
|
|
75
|
+
listen: false,
|
|
71
76
|
},
|
|
72
77
|
)
|
|
73
78
|
try {
|
|
74
79
|
const answer = await (
|
|
75
|
-
await
|
|
80
|
+
await app.root.router.request(`/ask`, {
|
|
76
81
|
method: 'POST',
|
|
77
82
|
headers: { 'content-type': 'application/json' },
|
|
78
83
|
body: JSON.stringify({ question: 'add a counter route' }),
|
|
79
84
|
})
|
|
80
85
|
).json()
|
|
81
86
|
assert.equal((answer as { output: string }).output, 'I built a counter: GET /count.')
|
|
82
|
-
assert.deepEqual(await (await
|
|
83
|
-
assert.deepEqual(await (await
|
|
87
|
+
assert.deepEqual(await (await app.root.router.request(`/count`)).json(), { count: 1 })
|
|
88
|
+
assert.deepEqual(await (await app.root.router.request(`/count`)).json(), { count: 2 })
|
|
84
89
|
} finally {
|
|
85
90
|
await app.stop()
|
|
86
91
|
await api.close()
|
|
@@ -30,8 +30,19 @@ export function readManifest(values?: Readonly<Record<string, ParameterValue>>)
|
|
|
30
30
|
})
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** The manifest without its server row: the app is then reached in process, through `root.router.request`. */
|
|
34
|
+
function withoutServer<M extends { plugins: readonly { use: string }[] }>(manifest: M): M {
|
|
35
|
+
return {
|
|
36
|
+
...manifest,
|
|
37
|
+
plugins: manifest.plugins.map((row) =>
|
|
38
|
+
row.use === 'acryl-node-server' ? { ...row, disabled: true } : row,
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
export interface App {
|
|
34
44
|
readonly root: Context
|
|
45
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
35
46
|
readonly url: string
|
|
36
47
|
stop(): Promise<void>
|
|
37
48
|
}
|
|
@@ -39,7 +50,11 @@ export interface App {
|
|
|
39
50
|
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
40
51
|
export async function createApp(
|
|
41
52
|
chosen: AppInstance,
|
|
42
|
-
options: {
|
|
53
|
+
options: {
|
|
54
|
+
values?: Readonly<Record<string, ParameterValue>>
|
|
55
|
+
/** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
|
|
56
|
+
listen?: boolean
|
|
57
|
+
} = {},
|
|
43
58
|
): Promise<App> {
|
|
44
59
|
const manifest = readManifest(options.values)
|
|
45
60
|
const root = new Context()
|
|
@@ -47,13 +62,18 @@ export async function createApp(
|
|
|
47
62
|
await first.await()
|
|
48
63
|
const fibers: Fiber[] = [
|
|
49
64
|
first,
|
|
50
|
-
...(await compose(
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
...(await compose(
|
|
66
|
+
root,
|
|
67
|
+
options.listen === false ? withoutServer(manifest) : manifest,
|
|
68
|
+
registry,
|
|
69
|
+
{
|
|
70
|
+
server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
|
|
71
|
+
},
|
|
72
|
+
)),
|
|
53
73
|
]
|
|
54
74
|
return {
|
|
55
75
|
root,
|
|
56
|
-
url: root.nodeServer.url,
|
|
76
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
57
77
|
async stop() {
|
|
58
78
|
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
59
79
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
2
3
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
-
import {
|
|
4
|
+
import { createServer } from 'node:net'
|
|
4
5
|
import { join } from 'node:path'
|
|
5
6
|
import { test } from 'node:test'
|
|
6
7
|
import { fileURLToPath } from 'node:url'
|
|
@@ -8,12 +9,18 @@ import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
|
|
|
8
9
|
import { type App, createApp } from '../src/app.ts'
|
|
9
10
|
import { APP_NAME } from '../src/routes.ts'
|
|
10
11
|
|
|
12
|
+
/** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
|
|
13
|
+
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
14
|
+
mkdirSync(scratch, { recursive: true })
|
|
15
|
+
|
|
11
16
|
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
12
17
|
|
|
13
18
|
/** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. */
|
|
14
19
|
async function withApp(run: (app: App) => Promise<void>) {
|
|
15
|
-
const home = await mkdtemp(join(
|
|
16
|
-
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0)
|
|
20
|
+
const home = await mkdtemp(join(scratch, 'app-test-'))
|
|
21
|
+
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
|
|
22
|
+
listen: false,
|
|
23
|
+
})
|
|
17
24
|
try {
|
|
18
25
|
await run(app)
|
|
19
26
|
} finally {
|
|
@@ -24,14 +31,14 @@ async function withApp(run: (app: App) => Promise<void>) {
|
|
|
24
31
|
|
|
25
32
|
test('serves the index route', () =>
|
|
26
33
|
withApp(async (app) => {
|
|
27
|
-
const response = await
|
|
34
|
+
const response = await app.root.router.request(`/`)
|
|
28
35
|
assert.equal(response.status, 200)
|
|
29
36
|
assert.deepEqual(await response.json(), { app: APP_NAME, ok: true })
|
|
30
37
|
}))
|
|
31
38
|
|
|
32
39
|
test('renders a .tsx page, and an error in it names the .tsx line', () =>
|
|
33
40
|
withApp(async (app) => {
|
|
34
|
-
const response = await
|
|
41
|
+
const response = await app.root.router.request(`/about`)
|
|
35
42
|
assert.equal(response.status, 200)
|
|
36
43
|
assert.match(await response.text(), /<main><h1>About<\/h1>/)
|
|
37
44
|
const { failingPage } = await import('../src/about.tsx')
|
|
@@ -46,14 +53,26 @@ test('renders a .tsx page, and an error in it names the .tsx line', () =>
|
|
|
46
53
|
|
|
47
54
|
test('answers health checks and 404 for unknown paths', () =>
|
|
48
55
|
withApp(async (app) => {
|
|
49
|
-
assert.equal((await
|
|
50
|
-
const missing = await
|
|
56
|
+
assert.equal((await app.root.router.request(`/health`)).status, 200)
|
|
57
|
+
const missing = await app.root.router.request(`/nope`)
|
|
51
58
|
assert.equal(missing.status, 404)
|
|
52
59
|
await missing.body?.cancel()
|
|
53
60
|
}))
|
|
54
61
|
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
/** A sandbox may forbid listening on a port (Codex's workspace-write does); the one test that starts the real server is skipped there, and says so. */
|
|
63
|
+
const canListen = await new Promise<boolean>((done) => {
|
|
64
|
+
const probe = createServer()
|
|
65
|
+
probe.once('error', () => done(false))
|
|
66
|
+
probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
|
|
67
|
+
})
|
|
68
|
+
const skip = canListen ? false : 'this environment does not allow listening on a port'
|
|
69
|
+
if (!canListen)
|
|
70
|
+
console.log(
|
|
71
|
+
`skipped: the test that starts the real server (${skip}); every other test ran in process`,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
test('stops listening when stopped', { skip }, async () => {
|
|
75
|
+
const home = await mkdtemp(join(scratch, 'app-test-'))
|
|
57
76
|
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
|
|
58
77
|
await app.stop()
|
|
59
78
|
await assert.rejects(fetch(`${app.url}/health`))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
|
|
2
|
+
## This app (chat): a streamed answer, history in a database
|
|
3
|
+
|
|
4
|
+
A Cordis app: `blend.yaml` lists the plugins that are mounted (`acryl-router`, `acryl-db-sqlite`, `routes`, `acryl-node-server`), `src/app.ts` is the one file that names the SQLite provider, and `src/routes.ts` is the app's own plugin: it builds the app on the `db` port and mounts it.
|
|
5
|
+
|
|
6
|
+
- **The dependency rule:** only `src/app.ts` (the `opener` in `package.json`) imports the provider. Every other file takes a `Db` (`import type { Db } from '../plugins/acryl-db/src/index.ts'`) as an argument and never imports `node:sqlite`, `pg`, another driver or `acryl-db-sqlite`. `npm test` and `npm run check` run `acrylblends check-deps`, which names the file and line of a violation; do not move the `opener` entry to silence it.
|
|
7
|
+
- `src/llm.ts`: `Llm`, what the app needs from a model (messages in, pieces out, an aborted signal stops the work), and `openAiCompatible`, which calls an OpenAI-compatible chat completions API with `stream: true`. The base URL is `DEEPSEEK_BASE_URL` (default `https://api.deepseek.com`), the model `DEEPSEEK_MODEL` (default `deepseek-chat`), the key **only** the environment variable `DEEPSEEK_API_KEY`, read at the moment of the call: never write it to a file, a response or a log.
|
|
8
|
+
- `src/db.ts`: `chatStore(db)`. The routes only call `chatStore`; keep SQL out of them. Every `db` call returns a promise: `await` it. The database is `data/chat.sqlite` (or the file `$CHAT_DB` names), opened by the `db` row with `migrations/` applied on start. The shape of the database is `migrations/NNN-name.sql`: to change it add the next numbered file and never edit one that has run. See `docs/framework/guides/data.md`.
|
|
9
|
+
- `src/chat-app.ts`: `createApp(db, llm)`. `POST /api/chat` answers Server-Sent Events: `meta` (the conversation id), a `token` per piece, then `done` or `error`. The request's signal (`c.req.raw.signal`) goes to the model call, so a closed tab or the Stop button stops the request to the model; what had arrived is kept.
|
|
10
|
+
- `src/client/chat.tsx`: the client component (own `tsconfig.json`, browser types): sends the message with `fetch`, reads the events as they arrive, shows the answer growing, and aborts on Stop. `clientModules` compiles it when the browser asks; `npm run build` compiles it once into `dist/client`. See `docs/framework/guides/client-modules.md`.
|
|
11
|
+
- `test/chat.test.ts` tests every route with a mock `Llm` (never the network); keep it that way. It boots the whole app on a database file in a throwaway home and takes `app.root.db`. `test/server.test.ts` starts the real server and stops it with a signal.
|
|
12
|
+
- To try it with a real model: `DEEPSEEK_API_KEY=... npm run dev`.
|
|
13
|
+
|
|
14
|
+
## Before you ship
|
|
15
|
+
|
|
16
|
+
Read `docs/framework/getting-started/deploy.md`. In short: `npm ci --omit=dev`, `npm run build`, `NODE_ENV=production npm start`; the app listens on `127.0.0.1` (`HOST=0.0.0.0` only in a container); the SQLite file goes outside the code folder (`CHAT_DB=/var/lib/chat/chat.sqlite`) and is backed up; the model key is an environment variable of the service (a root-only environment file for systemd), never a file in the repository. Anyone who can reach the app can spend your key: put it behind a login or on a private network before it is public.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# The app as data, in ACRYL's Blend format (blends.acryl.dev/v1alpha1): which plugins it mounts, in what order, with what config.
|
|
2
|
+
# Edit this, not app.ts, to switch a plugin off (disabled: true) or change its config. `npm run lock` records what the app is built from.
|
|
3
|
+
apiVersion: blends.acryl.dev/v1alpha1
|
|
4
|
+
kind: Blueprint
|
|
5
|
+
metadata:
|
|
6
|
+
id: acrylblends.__APP_NAME__
|
|
7
|
+
name: __APP_NAME__
|
|
8
|
+
version: 0.1.0
|
|
9
|
+
visibility: private # `npm run save` never pushes a private app to a public remote
|
|
10
|
+
spec:
|
|
11
|
+
runtime: cordis
|
|
12
|
+
parameters:
|
|
13
|
+
# The address the server listens on.
|
|
14
|
+
hostname: { type: string, default: 127.0.0.1 }
|
|
15
|
+
rows:
|
|
16
|
+
- id: router
|
|
17
|
+
name: acryl-router
|
|
18
|
+
# The db port, on one SQLite file. The file and the migrations folder are set in src/app.ts (data/chat.sqlite, or the file the CHAT_DB environment variable names).
|
|
19
|
+
- id: db
|
|
20
|
+
name: acryl-db-sqlite
|
|
21
|
+
- id: routes
|
|
22
|
+
name: routes
|
|
23
|
+
# Plugins installed while the app runs (the door's `plugin add`): development only, src/app.ts leaves it out when NODE_ENV=production.
|
|
24
|
+
- id: extensions
|
|
25
|
+
name: extensions
|
|
26
|
+
- id: server
|
|
27
|
+
name: acryl-node-server
|
|
28
|
+
config:
|
|
29
|
+
hostname: "{{parameters.hostname}}"
|
|
30
|
+
# The door: an agent working in this folder looks at the page and acts on it through .acryl/bin/acryl. Development only: src/app.ts leaves it out when NODE_ENV=production.
|
|
31
|
+
- id: acryl-door
|
|
32
|
+
name: acryl-door
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
CREATE TABLE conversations (
|
|
2
|
+
id INTEGER PRIMARY KEY,
|
|
3
|
+
title TEXT NOT NULL,
|
|
4
|
+
created_at TEXT NOT NULL
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
CREATE TABLE messages (
|
|
8
|
+
id INTEGER PRIMARY KEY,
|
|
9
|
+
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
10
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
|
11
|
+
content TEXT NOT NULL
|
|
12
|
+
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"imports": {
|
|
7
|
+
"#acryl-jsx/jsx-runtime": "./plugins/acryl-router/src/core/jsx/jsx-runtime.ts",
|
|
8
|
+
"#acryl-jsx/jsx-dev-runtime": "./plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22.19.0"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
|
|
15
|
+
"dev": "node --watch --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
|
|
16
|
+
"test": "acrylblends check-deps && node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
|
|
17
|
+
"check": "tsc --noEmit && tsc --noEmit -p src/client && acrylblends check-deps",
|
|
18
|
+
"build": "acrylblends build-client",
|
|
19
|
+
"lock": "node plugins/manifest/lock.ts",
|
|
20
|
+
"save": "node plugins/save/cli.ts",
|
|
21
|
+
"upgrade": "node plugins/manifest/upgrade-cli.ts"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"acrylblends": "__ACRYLBLENDS_RANGE__",
|
|
25
|
+
"typescript": "6.0.3"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^24.0.0"
|
|
29
|
+
},
|
|
30
|
+
"acryl": {
|
|
31
|
+
"opener": "src/app.ts"
|
|
32
|
+
}
|
|
33
|
+
}
|