acrylblends 1.3.0 → 1.3.2
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.js +5 -3
- package/dist/index.js.map +1 -1
- package/docs/framework/workshop/README.md +2 -0
- package/legacy/README.md +4 -1
- package/legacy/agent/858de7a1ad0b/src/app.ts +289 -0
- package/legacy/agent/858de7a1ad0b/src/routes.ts +145 -0
- package/legacy/agent/b96ba6e3598a/src/app.ts +285 -0
- package/legacy/agent/b96ba6e3598a/src/routes.ts +145 -0
- package/legacy/agent/ecbc84f1c1e5/src/app.ts +291 -0
- package/legacy/agent/ecbc84f1c1e5/src/routes.ts +145 -0
- package/legacy/blank/2447ac6d5d24/src/app.ts +61 -0
- package/legacy/blank/387f7e25aaeb/src/app.ts +81 -0
- package/legacy/blank/5b9f10e1ba5e/src/app.ts +61 -0
- package/legacy/blank/5f6dd8910c50/src/app.ts +152 -0
- package/legacy/blank/5f6dd8910c50/src/routes.ts +75 -0
- package/legacy/blank/a7e8f539ffee/src/app.ts +160 -0
- package/legacy/blank/a7e8f539ffee/src/routes.ts +75 -0
- package/legacy/blank/bdb9735e013b/src/app.ts +61 -0
- package/legacy/blank/c3be1d735b1a/src/app.ts +61 -0
- package/legacy/blank/c3be1d735b1a/src/routes.ts +18 -0
- package/legacy/blank/e04303a7f8dd/src/app.ts +61 -0
- package/legacy/chat/5120c4a9fa61/src/app.ts +157 -0
- package/legacy/chat/5120c4a9fa61/src/routes.ts +29 -0
- package/legacy/chat/d4464dfb3b04/src/app.ts +165 -0
- package/legacy/chat/d4464dfb3b04/src/routes.ts +29 -0
- package/legacy/frozen.json +7 -0
- package/legacy/layouts.json +135 -45
- package/legacy/nodejs/9d59115118c7/src/app.ts +150 -0
- package/legacy/nodejs/9d59115118c7/src/routes.ts +59 -0
- package/legacy/nodejs/a6a340b4d3a6/src/app.ts +158 -0
- package/legacy/nodejs/a6a340b4d3a6/src/routes.ts +59 -0
- package/legacy/notes/fb43a1c5f3b4/src/app.ts +165 -0
- package/legacy/notes/fb43a1c5f3b4/src/routes.ts +21 -0
- package/legacy/notes/fc3fe7c749d2/src/app.ts +157 -0
- package/legacy/notes/fc3fe7c749d2/src/routes.ts +21 -0
- package/legacy/todo/164274ffcf7f/src/app.ts +145 -0
- package/legacy/todo/164274ffcf7f/src/todo.ts +137 -0
- package/lib/cordis/door.d.ts +10 -1
- package/lib/cordis/door.js +11 -4
- package/package.json +1 -1
- package/plugins/acryl-door/src/host/door.ts +17 -5
- package/plugins/builder/index.ts +1 -1
- package/plugins/builder/plugin.json +1 -1
- package/plugins/extensions/index.ts +57 -2
- package/plugins/extensions/routes.ts +92 -0
- package/plugins/home/index.ts +3 -1
- package/plugins/plugin-share/README.md +16 -1
- package/plugins/plugin-share/index.ts +130 -16
- package/plugins/plugin-share/plugin.json +1 -1
- package/templates/agent/src/app.ts +2 -0
- package/templates/blank/blend.yaml +3 -0
- package/templates/blank/src/app.ts +8 -0
- package/templates/blank/template.json +1 -0
- package/templates/chat/blend.yaml +3 -0
- package/templates/chat/src/app.ts +8 -0
- package/templates/chat/template.json +1 -0
- package/templates/nodejs/blend.yaml +3 -0
- package/templates/nodejs/src/app.ts +8 -0
- package/templates/nodejs/template.json +1 -0
- package/templates/notes/blend.yaml +3 -0
- package/templates/notes/src/app.ts +8 -0
- package/templates/notes/template.json +1 -0
- /package/legacy/blank/{d6d371c9a65e → 2447ac6d5d24}/src/routes.ts +0 -0
- /package/legacy/blank/{5a91c558af22 → 387f7e25aaeb}/src/routes.ts +0 -0
- /package/legacy/blank/{b2cbb759c889 → 5b9f10e1ba5e}/src/routes.ts +0 -0
- /package/legacy/blank/{9c650cf52bf3 → bdb9735e013b}/src/routes.ts +0 -0
- /package/legacy/blank/{aea589283445 → e04303a7f8dd}/src/routes.ts +0 -0
- /package/plugins/{builder → plugin-share}/package.ts +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
4
|
+
import * as door from '../plugins/acryl-door/src/index.ts'
|
|
5
|
+
import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
|
|
6
|
+
import * as router from '../plugins/acryl-router/src/index.ts'
|
|
7
|
+
import * as state from '../plugins/acryl-state/src/index.ts'
|
|
8
|
+
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
9
|
+
import {
|
|
10
|
+
appPluginNames,
|
|
11
|
+
blueprintsIn,
|
|
12
|
+
compose,
|
|
13
|
+
loadAppPlugins,
|
|
14
|
+
loadManifest,
|
|
15
|
+
type ParameterValue,
|
|
16
|
+
} from '../plugins/manifest/index.ts'
|
|
17
|
+
import { doorEnabled } from './door.ts'
|
|
18
|
+
import * as todo from './todo.ts'
|
|
19
|
+
import { FRAME_MOUNT_PREFIX } from './todo.ts'
|
|
20
|
+
|
|
21
|
+
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them, and adds the app's own plugins from `plugins/` by name. */
|
|
22
|
+
export const registry = {
|
|
23
|
+
'acryl-router': router,
|
|
24
|
+
'acryl-state': state,
|
|
25
|
+
todo,
|
|
26
|
+
'acryl-node-server': nodeServer,
|
|
27
|
+
'acryl-door': door,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const APP_NAME = '__APP_NAME__'
|
|
31
|
+
|
|
32
|
+
/** The manifest next to this app's source. */
|
|
33
|
+
export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
|
|
34
|
+
/** The app's plugins: the library copies `registry` imports, and the app's own plugins (a feature each), which `blend.yaml` mounts by their folder name. */
|
|
35
|
+
const PLUGINS_DIR = join(fileURLToPath(new URL('..', import.meta.url)), 'plugins')
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
|
|
39
|
+
* `createApp` and the tests read it the same way.
|
|
40
|
+
*/
|
|
41
|
+
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
42
|
+
return loadManifest(MANIFEST, {
|
|
43
|
+
known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
|
|
44
|
+
getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
|
|
45
|
+
...(values ? { values } : {}),
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The manifest without some rows: without its server (and so its door) the app is reached in process, through `root.router.request`; the door is also left out in production. */
|
|
50
|
+
function without<M extends { plugins: readonly { use: string }[] }>(
|
|
51
|
+
manifest: M,
|
|
52
|
+
...uses: string[]
|
|
53
|
+
): M {
|
|
54
|
+
return {
|
|
55
|
+
...manifest,
|
|
56
|
+
plugins: manifest.plugins.map((row) =>
|
|
57
|
+
uses.includes(row.use) ? { ...row, disabled: true } : row,
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface App {
|
|
63
|
+
readonly root: Context
|
|
64
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
65
|
+
readonly url: string
|
|
66
|
+
stop(): Promise<void>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AppOptions {
|
|
70
|
+
readonly values?: Readonly<Record<string, ParameterValue>>
|
|
71
|
+
/** The path the app is mounted at; absent on its own server, where it keeps `/todo`, and {@link FRAME_MOUNT_PREFIX} in the frame. */
|
|
72
|
+
readonly prefix?: string
|
|
73
|
+
/** 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. */
|
|
74
|
+
readonly listen?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The rows the frame host provides: the app contributes only the plugins that are its own. */
|
|
78
|
+
const HOST_ROWS = [
|
|
79
|
+
'instance',
|
|
80
|
+
'acryl-router',
|
|
81
|
+
'acryl-node-server',
|
|
82
|
+
'acryl-web-server',
|
|
83
|
+
'acryl-page',
|
|
84
|
+
'manifest',
|
|
85
|
+
'save',
|
|
86
|
+
'acryl-door',
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The app's rows, composed into `ctx`: the app's own server calls this from `createApp`, and the generated frame layer mounts it as one row, so the frame
|
|
91
|
+
* runs the app exactly as the app's own server does (same rows, same order, same config). `host: true` leaves the infrastructure rows to the frame host,
|
|
92
|
+
* which is where the door lives too, and mounts the app at {@link FRAME_MOUNT_PREFIX}.
|
|
93
|
+
*/
|
|
94
|
+
export async function composeInto(
|
|
95
|
+
ctx: Context,
|
|
96
|
+
chosen: AppInstance,
|
|
97
|
+
options: AppOptions & { readonly host?: boolean } = {},
|
|
98
|
+
): Promise<Fiber[]> {
|
|
99
|
+
const read = readManifest(options.values)
|
|
100
|
+
const own = options.host !== true && options.listen !== false
|
|
101
|
+
// The frame host mounts the app under its own prefix; the app's own server keeps the app's old path.
|
|
102
|
+
const prefix = options.prefix ?? (options.host === true ? FRAME_MOUNT_PREFIX : undefined)
|
|
103
|
+
const leaveOut = new Set<string>(options.host === true ? HOST_ROWS : [])
|
|
104
|
+
// The server, and the door that needs it, are not mounted when the app is reached in process or inside the frame host.
|
|
105
|
+
if (!own) leaveOut.add('acryl-node-server').add('acryl-door')
|
|
106
|
+
if (!doorEnabled()) leaveOut.add('acryl-door')
|
|
107
|
+
const manifest = without(read, ...leaveOut)
|
|
108
|
+
// The rows that name one of the app's own plugin folders (a feature: plugins/<name>/ and its row) rather than a registry import; each one that runs is imported here.
|
|
109
|
+
const ownRows = [
|
|
110
|
+
...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
|
|
111
|
+
]
|
|
112
|
+
const ownPlugins = await loadAppPlugins(
|
|
113
|
+
PLUGINS_DIR,
|
|
114
|
+
ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
|
|
115
|
+
)
|
|
116
|
+
return compose(
|
|
117
|
+
ctx,
|
|
118
|
+
manifest,
|
|
119
|
+
{ ...registry, ...ownPlugins },
|
|
120
|
+
{
|
|
121
|
+
// Runtime values the manifest cannot hold: where the list is kept (in the app's home) and, in the frame, the prefix the app answers at.
|
|
122
|
+
todo: {
|
|
123
|
+
file: join(chosen.home, 'todo.json'),
|
|
124
|
+
...(prefix === undefined ? {} : { prefix }),
|
|
125
|
+
},
|
|
126
|
+
// Only the app's own server listens; the frame host owns the server and its port.
|
|
127
|
+
...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
133
|
+
export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
|
|
134
|
+
const root = new Context()
|
|
135
|
+
const first = root.plugin(instance, chosen)
|
|
136
|
+
await first.await()
|
|
137
|
+
const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
|
|
138
|
+
return {
|
|
139
|
+
root,
|
|
140
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
141
|
+
async stop() {
|
|
142
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference app of the ACRYL Framework: a todo list in one plugin. It needs `router` and `state`, keeps the list in a cell, serves
|
|
3
|
+
* JSON CRUD plus a page, and writes the list to a file so it survives a restart. Nothing else.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { dirname } from 'node:path'
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import { Acryl, HTTPException } from '../plugins/acryl-router/src/index.ts'
|
|
10
|
+
import { bodyLimit, loopbackOnly, trimTrailingSlash } from '../plugins/acryl-router/src/middleware/index.ts'
|
|
11
|
+
import { schemaValidator, streamSSE, type StandardSchema } from '../plugins/acryl-router/src/helpers/index.ts'
|
|
12
|
+
import type {} from '../plugins/acryl-state/src/index.ts'
|
|
13
|
+
import { pageHtml } from './page.ts'
|
|
14
|
+
|
|
15
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
16
|
+
export const name = 'acryl-todo'
|
|
17
|
+
export const inject = ['router', 'state']
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The only place the app's mount prefix is named: the app answers under `/todo` on its own server (`npm start`), and the ACRYL frame mounts the same app
|
|
21
|
+
* at `/app` (`npm run dev`). Every URL the page writes is derived from the prefix it is mounted at, so both are served by one source.
|
|
22
|
+
*/
|
|
23
|
+
export const FRAME_MOUNT_PREFIX = '/app'
|
|
24
|
+
|
|
25
|
+
export interface Todo { id: number, title: string, done: boolean }
|
|
26
|
+
export interface Config {
|
|
27
|
+
file?: string
|
|
28
|
+
/**
|
|
29
|
+
* The path the app is mounted at, and the base of every URL its page writes. `/todo` on the app's own server (and the default); the ACRYL frame passes
|
|
30
|
+
* `/app` so the same app answers at the frame's app prefix, with nothing else of its URLs moved.
|
|
31
|
+
*/
|
|
32
|
+
prefix?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Standard Schema for the body of a new todo, written by hand so the example has no schema library. */
|
|
36
|
+
const NewTodo: StandardSchema<{ title: string }> = {
|
|
37
|
+
'~standard': {
|
|
38
|
+
validate: value => {
|
|
39
|
+
const title = (value as { title?: unknown } | null)?.title
|
|
40
|
+
return typeof title === 'string' && title.trim() !== '' && title.length <= 200
|
|
41
|
+
? { value: { title: title.trim() } }
|
|
42
|
+
: { issues: [{ message: 'title must be 1 to 200 characters', path: ['title'] }] }
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Standard Schema for a change to a todo. */
|
|
48
|
+
const TodoChange: StandardSchema<{ done: boolean }> = {
|
|
49
|
+
'~standard': {
|
|
50
|
+
validate: value => {
|
|
51
|
+
const done = (value as { done?: unknown } | null)?.done
|
|
52
|
+
return typeof done === 'boolean' ? { value: { done } } : { issues: [{ message: 'done must be true or false', path: ['done'] }] }
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A todo body is small; anything bigger is refused before it is read. */
|
|
58
|
+
const MAX_BODY_BYTES = 16 * 1024
|
|
59
|
+
|
|
60
|
+
/** A missing file is a new list. Any other failure (unreadable, corrupt, half-written) refuses to start: starting empty would overwrite the user's real list on the first save. */
|
|
61
|
+
function load(file: string | undefined): Todo[] {
|
|
62
|
+
if (file === undefined) return []
|
|
63
|
+
let text: string
|
|
64
|
+
try {
|
|
65
|
+
text = readFileSync(file, 'utf8')
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
68
|
+
throw new Error(`acryl-todo: cannot read ${file}: ${(error as Error).message}`, { cause: error })
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const parsed: unknown = JSON.parse(text)
|
|
72
|
+
if (!Array.isArray(parsed)) throw new Error('not a list')
|
|
73
|
+
return parsed as Todo[]
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(`acryl-todo: ${file} is not a valid todo list (${(error as Error).message}); fix or remove it, it was left untouched`, { cause: error })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function save(file: string, todos: readonly Todo[]): void {
|
|
80
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
81
|
+
const temporary = `${file}.tmp`
|
|
82
|
+
writeFileSync(temporary, JSON.stringify(todos))
|
|
83
|
+
renameSync(temporary, file)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function apply(ctx: Context, config: Config = {}): void {
|
|
87
|
+
const prefix = config.prefix ?? '/todo'
|
|
88
|
+
const todos = ctx.state.cell<readonly Todo[]>('todo.list', load(config.file))
|
|
89
|
+
if (config.file !== undefined) {
|
|
90
|
+
const file = config.file
|
|
91
|
+
ctx.effect(() => todos.subscribe(list => { save(file, list) }), 'acryl-todo: save to file')
|
|
92
|
+
}
|
|
93
|
+
const nextId = (): number => todos.get().reduce((max, todo) => Math.max(max, todo.id), 0) + 1
|
|
94
|
+
|
|
95
|
+
const find = (id: number): Todo => {
|
|
96
|
+
const todo = todos.get().find(item => item.id === id)
|
|
97
|
+
if (todo === undefined) throw new HTTPException(404, { message: 'no such todo' })
|
|
98
|
+
return todo
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `/todo/` is what people type: trimTrailingSlash answers a GET for an unknown path ending in `/` with a 301 to the path without it,
|
|
102
|
+
// so the page is found either way while each route keeps one canonical path (the router itself treats `/a` and `/a/` as different).
|
|
103
|
+
const api = new Acryl()
|
|
104
|
+
.use(loopbackOnly({ inProcess: 'allow' }), bodyLimit({ maxSize: MAX_BODY_BYTES }), trimTrailingSlash())
|
|
105
|
+
.get('/', c => c.html(pageHtml(prefix)))
|
|
106
|
+
.get('/api', c => c.json(todos.get()))
|
|
107
|
+
.post('/api', schemaValidator('json', NewTodo), c => {
|
|
108
|
+
const todo: Todo = { id: nextId(), title: c.req.valid('json').title, done: false }
|
|
109
|
+
todos.update(list => [...list, todo])
|
|
110
|
+
return c.json(todo, 201)
|
|
111
|
+
})
|
|
112
|
+
.patch('/api/:id', schemaValidator('json', TodoChange), c => {
|
|
113
|
+
const { id } = find(Number(c.req.param('id')))
|
|
114
|
+
const { done } = c.req.valid('json')
|
|
115
|
+
todos.update(list => list.map(todo => todo.id === id ? { ...todo, done } : todo))
|
|
116
|
+
return c.json(find(id))
|
|
117
|
+
})
|
|
118
|
+
.delete('/api/:id', c => {
|
|
119
|
+
const { id } = find(Number(c.req.param('id')))
|
|
120
|
+
todos.update(list => list.filter(todo => todo.id !== id))
|
|
121
|
+
return c.body(null, 204)
|
|
122
|
+
})
|
|
123
|
+
.get('/events', c => streamSSE(c, async stream => {
|
|
124
|
+
const ended = new Promise<void>(resolve => { stream.onAbort(resolve) })
|
|
125
|
+
// Writes are asynchronous; chaining them keeps events in the order the list changed.
|
|
126
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
127
|
+
const send = (list: readonly Todo[]): void => { queue = queue.then(() => stream.writeSSE({ data: JSON.stringify(list) })).catch(() => {}) }
|
|
128
|
+
send(todos.get())
|
|
129
|
+
const unsubscribe = todos.subscribe(send)
|
|
130
|
+
try {
|
|
131
|
+
await ended
|
|
132
|
+
} finally {
|
|
133
|
+
unsubscribe()
|
|
134
|
+
}
|
|
135
|
+
}))
|
|
136
|
+
ctx.router.route(prefix === '' ? '/' : prefix, api)
|
|
137
|
+
}
|
package/lib/cordis/door.d.ts
CHANGED
|
@@ -96,8 +96,12 @@ interface ExtensionsPort {
|
|
|
96
96
|
reload(): Promise<unknown>;
|
|
97
97
|
list(): readonly unknown[];
|
|
98
98
|
}
|
|
99
|
-
/** The slice of acrylblends' `pluginShare` service the pull
|
|
99
|
+
/** The slice of acrylblends' `pluginShare` service the pull, list and remove verbs use. */
|
|
100
100
|
interface PluginSharePort {
|
|
101
|
+
/** The plugins installed while the app runs, a pulled one with its source and author. */
|
|
102
|
+
list(): readonly unknown[];
|
|
103
|
+
/** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
|
|
104
|
+
remove(name: string): Promise<unknown>;
|
|
101
105
|
pull(source: string): Promise<{
|
|
102
106
|
readonly ok: boolean;
|
|
103
107
|
readonly action?: string;
|
|
@@ -207,6 +211,11 @@ declare class Door {
|
|
|
207
211
|
private parse;
|
|
208
212
|
/** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
|
|
209
213
|
private callPage;
|
|
214
|
+
/**
|
|
215
|
+
* Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
|
|
216
|
+
* the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
|
|
217
|
+
*/
|
|
218
|
+
private installed;
|
|
210
219
|
private plugin;
|
|
211
220
|
private share;
|
|
212
221
|
/** Only a plan: no argument of any verb can publish, so an agent never says yes for a person. */
|
package/lib/cordis/door.js
CHANGED
|
@@ -706,14 +706,14 @@ var Door = class {
|
|
|
706
706
|
text: `done${result.target === void 0 ? "" : `: ${result.target.role} "${result.target.name}"`}${result.detail === void 0 ? "" : ` (${result.detail})`}; look again before the next act`
|
|
707
707
|
};
|
|
708
708
|
}
|
|
709
|
-
case "plugin-list": return this.plugin((
|
|
709
|
+
case "plugin-list": return this.plugin((plugins) => plugins.list(), false, this.installed());
|
|
710
710
|
case "plugin-add": return this.plugin((extensions) => extensions.install(this.insideApp(input.folder)));
|
|
711
711
|
case "plugin-pull": return this.pull(input.source);
|
|
712
712
|
case "plugin-publish": return this.planPublish(input);
|
|
713
713
|
case "plugin-remove": {
|
|
714
714
|
const name = input.name;
|
|
715
715
|
if (typeof name !== "string" || name === "") throw new DoorError("invalid", "plugin remove needs a plugin name");
|
|
716
|
-
return this.plugin((
|
|
716
|
+
return this.plugin((plugins) => plugins.remove(name), true, this.installed());
|
|
717
717
|
}
|
|
718
718
|
case "plugin-reload": return this.plugin((extensions) => extensions.reload());
|
|
719
719
|
case "tool-list": {
|
|
@@ -942,8 +942,15 @@ var Door = class {
|
|
|
942
942
|
throw cause;
|
|
943
943
|
}
|
|
944
944
|
}
|
|
945
|
-
|
|
946
|
-
|
|
945
|
+
/**
|
|
946
|
+
* Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
|
|
947
|
+
* the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
|
|
948
|
+
*/
|
|
949
|
+
installed() {
|
|
950
|
+
return this.deps.pluginShare?.() ?? this.deps.extensions();
|
|
951
|
+
}
|
|
952
|
+
async plugin(run, mutates = true, port = this.deps.extensions()) {
|
|
953
|
+
const extensions = port;
|
|
947
954
|
if (extensions === void 0) throw new DoorError("unavailable", "this door has no extensions plugin to drive. In ACRYL's frame none is needed: a saved change in src/ or plugins/ reloads the app by itself, so do not restart it. Elsewhere, the app has no way to install plugins while it runs");
|
|
948
955
|
const value = await run(extensions);
|
|
949
956
|
const unchanged = typeof value === "object" && value !== null && value.action === "unchanged";
|
package/package.json
CHANGED
|
@@ -35,8 +35,12 @@ export interface ExtensionsPort {
|
|
|
35
35
|
list(): readonly unknown[]
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** The slice of acrylblends' `pluginShare` service the pull
|
|
38
|
+
/** The slice of acrylblends' `pluginShare` service the pull, list and remove verbs use. */
|
|
39
39
|
export interface PluginSharePort {
|
|
40
|
+
/** The plugins installed while the app runs, a pulled one with its source and author. */
|
|
41
|
+
list(): readonly unknown[]
|
|
42
|
+
/** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
|
|
43
|
+
remove(name: string): Promise<unknown>
|
|
40
44
|
pull(source: string): Promise<{ readonly ok: boolean; readonly action?: string; readonly reason?: string; readonly next?: string }>
|
|
41
45
|
planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | { readonly ok: false; readonly reason: string; readonly next: string }>
|
|
42
46
|
publish(name: string, confirmed: PublishPlanView, options?: PublishOptionsView): Promise<{ readonly ok: boolean; readonly reason?: string }>
|
|
@@ -153,14 +157,14 @@ export class Door {
|
|
|
153
157
|
const result = await this.act(input, signal, scope)
|
|
154
158
|
return { value: result, text: `done${result.target === undefined ? '' : `: ${result.target.role} "${result.target.name}"`}${result.detail === undefined ? '' : ` (${result.detail})`}; look again before the next act` }
|
|
155
159
|
}
|
|
156
|
-
case 'plugin-list': return this.plugin(
|
|
160
|
+
case 'plugin-list': return this.plugin(plugins => plugins.list(), false, this.installed())
|
|
157
161
|
case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
|
|
158
162
|
case 'plugin-pull': return this.pull(input.source)
|
|
159
163
|
case 'plugin-publish': return this.planPublish(input)
|
|
160
164
|
case 'plugin-remove': {
|
|
161
165
|
const name = input.name
|
|
162
166
|
if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin remove needs a plugin name')
|
|
163
|
-
return this.plugin(
|
|
167
|
+
return this.plugin(plugins => plugins.remove(name), true, this.installed())
|
|
164
168
|
}
|
|
165
169
|
case 'plugin-reload': return this.plugin(extensions => extensions.reload())
|
|
166
170
|
case 'tool-list': {
|
|
@@ -337,8 +341,16 @@ export class Door {
|
|
|
337
341
|
}
|
|
338
342
|
}
|
|
339
343
|
|
|
340
|
-
|
|
341
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
|
|
346
|
+
* the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
|
|
347
|
+
*/
|
|
348
|
+
private installed(): Pick<ExtensionsPort, 'list' | 'remove'> | undefined {
|
|
349
|
+
return this.deps.pluginShare?.() ?? this.deps.extensions()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private async plugin<Port = ExtensionsPort>(run: (plugins: Port) => unknown, mutates = true, port: Port | undefined = this.deps.extensions() as Port | undefined): Promise<VerbAnswer> {
|
|
353
|
+
const extensions = port
|
|
342
354
|
if (extensions === undefined) throw new DoorError('unavailable', 'this door has no extensions plugin to drive. In ACRYL\'s frame none is needed: a saved change in src/ or plugins/ reloads the app by itself, so do not restart it. Elsewhere, the app has no way to install plugins while it runs')
|
|
343
355
|
const value = await run(extensions)
|
|
344
356
|
// The app changed under the open pages: reload them (not when the plugin was already as it should be), and answer once they are back.
|
package/plugins/builder/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
11
11
|
import { scripted, type Tools } from '../agent/index.ts'
|
|
12
12
|
import { NAME } from '../extensions/index.ts'
|
|
13
13
|
import type {} from '../instance/index.ts'
|
|
14
|
-
import { packagePlugin } from '
|
|
14
|
+
import { packagePlugin } from '../plugin-share/package.ts'
|
|
15
15
|
import { builderRules } from './script.ts'
|
|
16
16
|
|
|
17
17
|
export interface BuilderConfig {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "builder",
|
|
3
3
|
"description": "The agent's tools for building the app it runs in: write, install, read, list and remove extensions, with real results.",
|
|
4
|
-
"requires": ["agent", "extensions", "sandbox"],
|
|
4
|
+
"requires": ["agent", "extensions", "sandbox", "plugin-share"],
|
|
5
5
|
"inject": ["extensions", "tools", "appInstance"],
|
|
6
6
|
"provides": [],
|
|
7
7
|
"dependencies": {
|
|
@@ -32,6 +32,13 @@ import type {} from '../instance/index.ts'
|
|
|
32
32
|
import type { Sandbox } from '../sandbox/index.ts'
|
|
33
33
|
import { appendLedger, type LedgerEntry, readLedger } from './ledger.ts'
|
|
34
34
|
import { type PluginStore, pluginVersion } from './plugin-store.ts'
|
|
35
|
+
import {
|
|
36
|
+
addedRoutes,
|
|
37
|
+
type FiberRoutesView,
|
|
38
|
+
routeClash,
|
|
39
|
+
type ServedRoute,
|
|
40
|
+
servedRoutes,
|
|
41
|
+
} from './routes.ts'
|
|
35
42
|
import { folderPluginStore } from './stage.ts'
|
|
36
43
|
|
|
37
44
|
/** A fiber's lifecycle state, by name (Cordis's own enum is a `const enum`, which does not exist at run time). */
|
|
@@ -93,6 +100,8 @@ export interface ExtensionsConfig {
|
|
|
93
100
|
* are not extensions, so they are neither found nor started here, and installing one is refused with that reason.
|
|
94
101
|
*/
|
|
95
102
|
rows?: readonly string[]
|
|
103
|
+
/** The path the app is mounted under (the frame's canvas, `/app`): a message names a route as the plugin wrote it, without it. */
|
|
104
|
+
routePrefix?: string
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
const READ_ONLY =
|
|
@@ -121,7 +130,7 @@ export type InstallResult =
|
|
|
121
130
|
| {
|
|
122
131
|
readonly ok: false
|
|
123
132
|
/** check: before anything changed; import: loading the code; activate: starting it; verify: its own `check` failed. */
|
|
124
|
-
readonly stage: 'check' | 'import' | 'activate' | 'verify' | 'approval'
|
|
133
|
+
readonly stage: 'check' | 'import' | 'activate' | 'conflict' | 'verify' | 'approval'
|
|
125
134
|
readonly name?: string
|
|
126
135
|
readonly errors: readonly string[]
|
|
127
136
|
/** Present for an update: whether the version that was running is running again. */
|
|
@@ -157,6 +166,8 @@ interface Installed {
|
|
|
157
166
|
readonly dir: string
|
|
158
167
|
readonly module: PluginModule
|
|
159
168
|
fiber: Fiber
|
|
169
|
+
/** The routes its mount added, so a later plugin whose route is taken is told whose it is. */
|
|
170
|
+
readonly routes: readonly ServedRoute[]
|
|
160
171
|
}
|
|
161
172
|
|
|
162
173
|
interface Record_ {
|
|
@@ -245,11 +256,13 @@ export class Extensions extends Service {
|
|
|
245
256
|
*/
|
|
246
257
|
private readonly owner: Context
|
|
247
258
|
private readonly store: PluginStore
|
|
259
|
+
private readonly routePrefix: string
|
|
248
260
|
|
|
249
261
|
constructor(ctx: Context, config: ExtensionsConfig, store: PluginStore) {
|
|
250
262
|
super(ctx, 'extensions')
|
|
251
263
|
this.owner = ctx
|
|
252
264
|
this.store = store
|
|
265
|
+
this.routePrefix = (config.routePrefix ?? '').replace(/\/+$/u, '')
|
|
253
266
|
this.readOnly = config.readOnly === true
|
|
254
267
|
this.off = new Set(config.off ?? [])
|
|
255
268
|
this.rows = new Set(config.rows ?? [])
|
|
@@ -588,6 +601,41 @@ export class Extensions extends Service {
|
|
|
588
601
|
}
|
|
589
602
|
|
|
590
603
|
/** Mount a module as a child fiber and wait until it settles. */
|
|
604
|
+
/** Every route the app serves now, read from the router's labels on every running fiber. */
|
|
605
|
+
private served(): ServedRoute[] {
|
|
606
|
+
const fibers = [...this.ctx.registry.values()].flatMap(
|
|
607
|
+
(runtime) => [...runtime.fibers] as unknown as FiberRoutesView[],
|
|
608
|
+
)
|
|
609
|
+
return servedRoutes(fibers)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* A route the new plugin added that another plugin already serves: the router would answer with the other one, so the new route would never be
|
|
614
|
+
* reached. Refused before the plugin's own check, which the other plugin's answer would pass.
|
|
615
|
+
*/
|
|
616
|
+
private conflict(
|
|
617
|
+
added: readonly ServedRoute[],
|
|
618
|
+
before: readonly ServedRoute[],
|
|
619
|
+
): { stage: 'conflict'; error: string } | undefined {
|
|
620
|
+
const clash = routeClash(added, before)
|
|
621
|
+
if (clash === undefined) return undefined
|
|
622
|
+
const { route, taken } = clash
|
|
623
|
+
const owner =
|
|
624
|
+
[...this.installed.values()].find((entry) =>
|
|
625
|
+
entry.routes.some((own) => own.method === taken.method && own.path === taken.path),
|
|
626
|
+
)?.name ?? (taken.owner === '' ? 'the app' : taken.owner)
|
|
627
|
+
const shown = (path: string): string =>
|
|
628
|
+
this.routePrefix !== '' && path.startsWith(`${this.routePrefix}/`)
|
|
629
|
+
? path.slice(this.routePrefix.length)
|
|
630
|
+
: path
|
|
631
|
+
const same =
|
|
632
|
+
taken.path === route.path ? '' : ` (it serves ${taken.method} ${shown(taken.path)})`
|
|
633
|
+
return {
|
|
634
|
+
stage: 'conflict',
|
|
635
|
+
error: `${route.method} ${shown(route.path)} is already served by ${owner}${same}; rename this plugin's route, or remove ${owner} first`,
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
591
639
|
private async mount(module: PluginModule): Promise<{ fiber: Fiber; error?: unknown }> {
|
|
592
640
|
// An extension is a plugin module whose config, if any, comes from its own defaults.
|
|
593
641
|
const fiber = this.owner.plugin(module as unknown as Parameters<Context['plugin']>[0])
|
|
@@ -666,8 +714,10 @@ export class Extensions extends Service {
|
|
|
666
714
|
}
|
|
667
715
|
|
|
668
716
|
if (previous) await previous.fiber.dispose()
|
|
717
|
+
const before = this.served()
|
|
669
718
|
const mounted = await this.mount(module)
|
|
670
719
|
const state = STATES[mounted.fiber.state] ?? 'failed'
|
|
720
|
+
const added = state === 'active' ? addedRoutes(before, this.served()) : []
|
|
671
721
|
// Commit before "done": a plugin that started still has to pass its own check before the install counts.
|
|
672
722
|
const failure =
|
|
673
723
|
mounted.error !== undefined || state === 'failed'
|
|
@@ -676,7 +726,7 @@ export class Extensions extends Service {
|
|
|
676
726
|
error: message(mounted.error ?? 'the plugin failed to start'),
|
|
677
727
|
}
|
|
678
728
|
: state === 'active'
|
|
679
|
-
? await this.verify(module, mounted.fiber.ctx)
|
|
729
|
+
? (this.conflict(added, before) ?? (await this.verify(module, mounted.fiber.ctx)))
|
|
680
730
|
: undefined
|
|
681
731
|
if (failure) {
|
|
682
732
|
await mounted.fiber.dispose().catch(() => undefined)
|
|
@@ -714,6 +764,7 @@ export class Extensions extends Service {
|
|
|
714
764
|
dir: staged.dir,
|
|
715
765
|
module,
|
|
716
766
|
fiber: mounted.fiber,
|
|
767
|
+
routes: added,
|
|
717
768
|
})
|
|
718
769
|
this.stale.delete(name)
|
|
719
770
|
this.saveState()
|
|
@@ -758,6 +809,7 @@ export class Extensions extends Service {
|
|
|
758
809
|
}
|
|
759
810
|
try {
|
|
760
811
|
const module = await this.load(folder.dir, folder.name)
|
|
812
|
+
const before = this.served()
|
|
761
813
|
const mounted = await this.mount(module)
|
|
762
814
|
this.installed.set(folder.name, {
|
|
763
815
|
name: folder.name,
|
|
@@ -766,6 +818,7 @@ export class Extensions extends Service {
|
|
|
766
818
|
dir: folder.dir,
|
|
767
819
|
module,
|
|
768
820
|
fiber: mounted.fiber,
|
|
821
|
+
routes: addedRoutes(before, this.served()),
|
|
769
822
|
})
|
|
770
823
|
if (mounted.error !== undefined)
|
|
771
824
|
summary.failed.push({ name: folder.name, error: message(mounted.error) })
|
|
@@ -830,6 +883,7 @@ export class Extensions extends Service {
|
|
|
830
883
|
continue
|
|
831
884
|
}
|
|
832
885
|
const module = await this.load(record.dir, name)
|
|
886
|
+
const before = this.served()
|
|
833
887
|
const mounted = await this.mount(module)
|
|
834
888
|
if (mounted.error !== undefined)
|
|
835
889
|
summary.failed.push({ name, error: message(mounted.error) })
|
|
@@ -840,6 +894,7 @@ export class Extensions extends Service {
|
|
|
840
894
|
dir: record.dir,
|
|
841
895
|
module,
|
|
842
896
|
fiber: mounted.fiber,
|
|
897
|
+
routes: addedRoutes(before, this.served()),
|
|
843
898
|
})
|
|
844
899
|
} catch (error) {
|
|
845
900
|
summary.failed.push({ name, error: message(error) })
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which routes a plugin brings, and whether one of them is already served (spec 049 T025). Plugins that do not know each other share one app: the router
|
|
3
|
+
* serves the first mount of a route and ignores the second without a word, so a plugin whose route is taken would look installed and answer nothing.
|
|
4
|
+
* The router labels each route it serves (`acryl-router: serves GET /notes`, under the mount's effect), so the routes are read from the running system.
|
|
5
|
+
* Pure functions over those labels; the service reads them from the registry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** One route as the router serves it. */
|
|
9
|
+
export interface ServedRoute {
|
|
10
|
+
readonly method: string
|
|
11
|
+
readonly path: string
|
|
12
|
+
/** The plugin whose fiber holds the label. */
|
|
13
|
+
readonly owner: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The slice of a Cordis effect this module reads. */
|
|
17
|
+
export interface EffectView {
|
|
18
|
+
readonly label: string
|
|
19
|
+
readonly children: readonly EffectView[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The slice of a Cordis fiber this module reads. */
|
|
23
|
+
export interface FiberRoutesView {
|
|
24
|
+
readonly runtime: { readonly name?: string | undefined } | null
|
|
25
|
+
getEffects(): readonly EffectView[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SERVES = /^acryl-router: serves ([A-Z]+) (\S+)$/u
|
|
29
|
+
|
|
30
|
+
/** Every route served by the given fibers, with the plugin that holds each. `ALL` is middleware or `all`, which wraps routes rather than taking them. */
|
|
31
|
+
export function servedRoutes(fibers: Iterable<FiberRoutesView>): ServedRoute[] {
|
|
32
|
+
const routes: ServedRoute[] = []
|
|
33
|
+
const walk = (effects: readonly EffectView[], owner: string): void => {
|
|
34
|
+
for (const effect of effects) {
|
|
35
|
+
const served = SERVES.exec(effect.label)
|
|
36
|
+
if (served?.[1] !== undefined && served[2] !== undefined && served[1] !== 'ALL')
|
|
37
|
+
routes.push({ method: served[1], path: served[2], owner })
|
|
38
|
+
walk(effect.children, owner)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const fiber of fibers) walk(fiber.getEffects(), fiber.runtime?.name ?? '')
|
|
42
|
+
return routes
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const key = (route: ServedRoute): string => `${route.method} ${route.path}`
|
|
46
|
+
|
|
47
|
+
/** The routes in `after` that `before` did not have: what one mount added, counted, so a second mount of the same route counts as added. */
|
|
48
|
+
export function addedRoutes(
|
|
49
|
+
before: readonly ServedRoute[],
|
|
50
|
+
after: readonly ServedRoute[],
|
|
51
|
+
): ServedRoute[] {
|
|
52
|
+
const left = new Map<string, number>()
|
|
53
|
+
for (const route of before) left.set(key(route), (left.get(key(route)) ?? 0) + 1)
|
|
54
|
+
const added: ServedRoute[] = []
|
|
55
|
+
for (const route of after) {
|
|
56
|
+
const count = left.get(key(route)) ?? 0
|
|
57
|
+
if (count > 0) left.set(key(route), count - 1)
|
|
58
|
+
else added.push(route)
|
|
59
|
+
}
|
|
60
|
+
return added
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const segments = (path: string): string[] => path.split('/').filter((part) => part !== '')
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether one request could match both paths: equal segments, a `:param` matching any one segment, a `*` matching the rest. `/notes` and `/notes/list` are
|
|
67
|
+
* two routes; `/notes/*` and `/notes/list` are not.
|
|
68
|
+
*/
|
|
69
|
+
export function pathsOverlap(a: string, b: string): boolean {
|
|
70
|
+
const x = segments(a)
|
|
71
|
+
const y = segments(b)
|
|
72
|
+
for (let i = 0; ; i += 1) {
|
|
73
|
+
const p = x[i]
|
|
74
|
+
const q = y[i]
|
|
75
|
+
if (p === '*' || q === '*') return true
|
|
76
|
+
if (p === undefined || q === undefined) return p === q
|
|
77
|
+
if (p.startsWith(':') || q.startsWith(':')) continue
|
|
78
|
+
if (p !== q) return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The first route `added` shares with one already served: same method and an overlapping path. */
|
|
83
|
+
export function routeClash(
|
|
84
|
+
added: readonly ServedRoute[],
|
|
85
|
+
served: readonly ServedRoute[],
|
|
86
|
+
): { readonly route: ServedRoute; readonly taken: ServedRoute } | undefined {
|
|
87
|
+
for (const route of added)
|
|
88
|
+
for (const taken of served)
|
|
89
|
+
if (taken.method === route.method && pathsOverlap(taken.path, route.path))
|
|
90
|
+
return { route, taken }
|
|
91
|
+
return undefined
|
|
92
|
+
}
|