acrylblends 0.0.0-stage → 0.2.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/bin/acrylblends.js +4 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +434 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -4
- package/plugins/agent/agent.ts +152 -0
- package/plugins/agent/index.ts +20 -0
- package/plugins/agent/memory.ts +24 -0
- package/plugins/agent/model.ts +79 -0
- package/plugins/agent/plugin.json +10 -0
- package/plugins/agent/tools.ts +50 -0
- package/plugins/builder/index.ts +193 -0
- package/plugins/builder/plugin.json +11 -0
- package/plugins/builder/script.ts +162 -0
- package/plugins/events/index.ts +42 -0
- package/plugins/events/plugin.json +9 -0
- package/plugins/extensions/index.ts +738 -0
- package/plugins/extensions/ledger.ts +39 -0
- package/plugins/extensions/plugin.json +12 -0
- package/plugins/extensions/resolve.ts +30 -0
- package/plugins/extensions/stage.ts +44 -0
- package/plugins/http/index.ts +64 -0
- package/plugins/http/plugin.json +10 -0
- package/plugins/instance/index.ts +167 -0
- package/plugins/instance/plugin.json +10 -0
- package/plugins/manifest/index.ts +635 -0
- package/plugins/manifest/lock.ts +94 -0
- package/plugins/manifest/plugin.json +13 -0
- package/plugins/manifest/upgrade-cli.ts +70 -0
- package/plugins/manifest/upgrade.ts +97 -0
- package/plugins/openai/index.ts +159 -0
- package/plugins/openai/plugin.json +11 -0
- package/plugins/openai/prompt.ts +31 -0
- package/plugins/sandbox/index.ts +150 -0
- package/plugins/sandbox/plugin.json +8 -0
- package/plugins/sandbox/runner.mjs +18 -0
- package/plugins/save/cli.ts +19 -0
- package/plugins/save/index.ts +55 -0
- package/plugins/save/plugin.json +12 -0
- package/plugins/server/index.ts +138 -0
- package/plugins/server/plugin.json +10 -0
- package/plugins/settings/index.ts +270 -0
- package/plugins/settings/merge.ts +79 -0
- package/plugins/settings/plugin.json +13 -0
- package/plugins/tasks/index.ts +232 -0
- package/plugins/tasks/plugin.json +11 -0
- package/plugins/tasks/store.ts +86 -0
- package/plugins/tsconfig.json +19 -0
- package/templates/agent/Dockerfile +15 -0
- package/templates/agent/README.md +52 -0
- package/templates/agent/blend.yaml +58 -0
- package/templates/agent/dockerignore +3 -0
- package/templates/agent/gitignore +2 -0
- package/templates/agent/package.json +23 -0
- package/templates/agent/src/app.ts +98 -0
- package/templates/agent/src/main.ts +25 -0
- package/templates/agent/src/routes.ts +62 -0
- package/templates/agent/template.json +16 -0
- package/templates/agent/test/agent.test.ts +45 -0
- package/templates/agent/test/app.test.ts +46 -0
- package/templates/agent/test/builder.test.ts +186 -0
- package/templates/agent/test/model.test.ts +89 -0
- package/templates/agent/tsconfig.json +19 -0
- package/templates/app/README.md +26 -0
- package/templates/app/blend.yaml +23 -0
- package/templates/app/gitignore +2 -0
- package/templates/app/package.json +23 -0
- package/templates/app/src/app.ts +61 -0
- package/templates/app/src/main.ts +20 -0
- package/templates/app/src/routes.ts +14 -0
- package/templates/app/template.json +1 -0
- package/templates/app/test/app.test.ts +56 -0
- package/templates/app/tsconfig.json +19 -0
- package/README.md +0 -3
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The save plugin: gives an agent a `save_app` tool when the app has a tool registry. Saving itself is `saveApp` from
|
|
3
|
+
* `@webboxes/app-persistence` (ACRYL's own) over the user's own git; `npm run save` runs the same use case from a terminal (cli.ts).
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import { gitCli, githubHosting, type SaveResult, saveApp } from '@webboxes/app-persistence'
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
findSecrets,
|
|
12
|
+
type GitPort,
|
|
13
|
+
gitCli,
|
|
14
|
+
githubHosting,
|
|
15
|
+
type HostingPort,
|
|
16
|
+
PersistenceError,
|
|
17
|
+
readAppIdentity,
|
|
18
|
+
type SaveResult,
|
|
19
|
+
type SecretFinding,
|
|
20
|
+
saveApp,
|
|
21
|
+
type Visibility,
|
|
22
|
+
} from '@webboxes/app-persistence'
|
|
23
|
+
|
|
24
|
+
/** Save the app folder: its blend.yaml decides the visibility. */
|
|
25
|
+
export function saveFolder(appRoot: string, message: string): SaveResult {
|
|
26
|
+
const manifestText = readFileSync(join(appRoot, 'blend.yaml'), 'utf8')
|
|
27
|
+
return saveApp({ manifestText, message }, gitCli(appRoot), githubHosting(appRoot))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ToolRegistry {
|
|
31
|
+
register(name: string, tool: { description: string; run(input: unknown): unknown }): () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const save = {
|
|
35
|
+
name: 'save',
|
|
36
|
+
inject: ['appInstance'],
|
|
37
|
+
apply(ctx: Context) {
|
|
38
|
+
const registry = (ctx.get as (name: string) => unknown)('tools') as ToolRegistry | undefined
|
|
39
|
+
if (!registry) return
|
|
40
|
+
const root = (ctx.get as (name: string) => { root: string })('appInstance').root
|
|
41
|
+
ctx.effect(() =>
|
|
42
|
+
registry.register('save_app', {
|
|
43
|
+
description:
|
|
44
|
+
'Commit the app (and push if it has a remote). Refuses a file that holds a secret. Input { message }.',
|
|
45
|
+
run: (input) => {
|
|
46
|
+
const message = (input as { message?: unknown } | undefined)?.message
|
|
47
|
+
return saveFolder(
|
|
48
|
+
root,
|
|
49
|
+
typeof message === 'string' && message.trim() ? message : 'save the app',
|
|
50
|
+
)
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
},
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "save",
|
|
3
|
+
"description": "Save the app to its own git repository: refuses a file that holds a secret, and a private app going to a public remote.",
|
|
4
|
+
"requires": [],
|
|
5
|
+
"inject": ["appInstance"],
|
|
6
|
+
"provides": [],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
+
"@webboxes/app-persistence": "0.1.0"
|
|
10
|
+
},
|
|
11
|
+
"mount": "add a script \"save\": \"node src/plugins/save/cli.ts\" to package.json; mount the plugin to give the agent a save_app tool"
|
|
12
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createServer,
|
|
3
|
+
type IncomingMessage,
|
|
4
|
+
type Server as NodeServer,
|
|
5
|
+
type ServerResponse,
|
|
6
|
+
} from 'node:http'
|
|
7
|
+
import type { AddressInfo } from 'node:net'
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import type { Send, Stream } from '../http/index.ts'
|
|
10
|
+
|
|
11
|
+
/** Where the server listens. Passed as this plugin's Cordis config. */
|
|
12
|
+
export interface ServerConfig {
|
|
13
|
+
readonly port: number
|
|
14
|
+
readonly hostname?: string
|
|
15
|
+
/** Try the next port when this one is taken (up to 20 more). */
|
|
16
|
+
readonly scan?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The running server, provided once it listens. */
|
|
20
|
+
export interface Server {
|
|
21
|
+
readonly url: string
|
|
22
|
+
readonly port: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
declare module '@deepseek-ai/cordis' {
|
|
26
|
+
interface Context {
|
|
27
|
+
server: Server
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SCAN_LIMIT = 20
|
|
32
|
+
|
|
33
|
+
async function readJson(
|
|
34
|
+
request: IncomingMessage,
|
|
35
|
+
): Promise<{ ok: true; body: unknown } | { ok: false }> {
|
|
36
|
+
const chunks: Buffer[] = []
|
|
37
|
+
for await (const chunk of request) chunks.push(chunk as Buffer)
|
|
38
|
+
if (chunks.length === 0) return { ok: true, body: undefined }
|
|
39
|
+
try {
|
|
40
|
+
return { ok: true, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) }
|
|
41
|
+
} catch {
|
|
42
|
+
return { ok: false }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function listen(listener: NodeServer, port: number, hostname: string): Promise<void> {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const onError = (error: Error) => {
|
|
49
|
+
listener.off('listening', onListening)
|
|
50
|
+
reject(error)
|
|
51
|
+
}
|
|
52
|
+
const onListening = () => {
|
|
53
|
+
listener.off('error', onError)
|
|
54
|
+
resolve()
|
|
55
|
+
}
|
|
56
|
+
listener.once('error', onError)
|
|
57
|
+
listener.once('listening', onListening)
|
|
58
|
+
listener.listen(port, hostname)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const KEEPALIVE_MS = 15_000
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Answer with server-sent events. Each event has a rising `id`, a `topic` as its event name and JSON `data`; a comment line every 15 s
|
|
66
|
+
* keeps proxies from closing an idle stream. When the client leaves (or the server stops) the stream's own cleanup runs.
|
|
67
|
+
*/
|
|
68
|
+
function openStream(request: IncomingMessage, response: ServerResponse, stream: Stream): void {
|
|
69
|
+
response.writeHead(200, {
|
|
70
|
+
'content-type': 'text/event-stream',
|
|
71
|
+
'cache-control': 'no-cache',
|
|
72
|
+
connection: 'keep-alive',
|
|
73
|
+
})
|
|
74
|
+
response.write(': connected\n\n')
|
|
75
|
+
let id = 0
|
|
76
|
+
let open = true
|
|
77
|
+
const send: Send = (topic, data) => {
|
|
78
|
+
if (!open) return
|
|
79
|
+
id += 1
|
|
80
|
+
response.write(`id: ${id}\nevent: ${topic}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
81
|
+
}
|
|
82
|
+
const leave = stream(send)
|
|
83
|
+
const keepalive = setInterval(() => response.write(': keepalive\n\n'), KEEPALIVE_MS)
|
|
84
|
+
const close = () => {
|
|
85
|
+
if (!open) return
|
|
86
|
+
open = false
|
|
87
|
+
clearInterval(keepalive)
|
|
88
|
+
leave()
|
|
89
|
+
}
|
|
90
|
+
request.on('close', close)
|
|
91
|
+
response.on('close', close)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Serves the `http` router with Node's own `node:http`. The returned disposer closes the server. */
|
|
95
|
+
export const server = {
|
|
96
|
+
name: 'server',
|
|
97
|
+
inject: ['http'],
|
|
98
|
+
async apply(ctx: Context, config: ServerConfig) {
|
|
99
|
+
const hostname = config.hostname ?? '127.0.0.1'
|
|
100
|
+
const listener = createServer(async (request, response) => {
|
|
101
|
+
const send = (status: number, body: unknown) => {
|
|
102
|
+
response.writeHead(status, { 'content-type': 'application/json' })
|
|
103
|
+
response.end(JSON.stringify(body))
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const parsed = await readJson(request)
|
|
107
|
+
if (!parsed.ok) return send(400, { error: 'invalid JSON' })
|
|
108
|
+
const path = new URL(request.url ?? '/', 'http://localhost').pathname
|
|
109
|
+
const reply = await ctx.http.handle({
|
|
110
|
+
method: request.method ?? 'GET',
|
|
111
|
+
path,
|
|
112
|
+
body: parsed.body,
|
|
113
|
+
})
|
|
114
|
+
if (reply.stream) return openStream(request, response, reply.stream)
|
|
115
|
+
send(reply.status, reply.body)
|
|
116
|
+
} catch (error) {
|
|
117
|
+
send(500, { error: error instanceof Error ? error.message : String(error) })
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
const attempts = config.scan && config.port !== 0 ? SCAN_LIMIT + 1 : 1
|
|
121
|
+
for (let attempt = 0; ; attempt++) {
|
|
122
|
+
try {
|
|
123
|
+
await listen(listener, config.port + attempt, hostname)
|
|
124
|
+
break
|
|
125
|
+
} catch (error) {
|
|
126
|
+
const inUse = (error as NodeJS.ErrnoException).code === 'EADDRINUSE'
|
|
127
|
+
if (!inUse || attempt + 1 >= attempts) throw error
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const { port } = listener.address() as AddressInfo
|
|
131
|
+
ctx.provide('server', { url: `http://${hostname}:${port}`, port } satisfies Server)
|
|
132
|
+
return () =>
|
|
133
|
+
new Promise<void>((done) => {
|
|
134
|
+
listener.close(() => done())
|
|
135
|
+
listener.closeAllConnections()
|
|
136
|
+
})
|
|
137
|
+
},
|
|
138
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "server",
|
|
3
|
+
"description": "Serves the http router over node:http; moves to the next free port when asked; closes on stop.",
|
|
4
|
+
"requires": ["http"],
|
|
5
|
+
"inject": ["http"],
|
|
6
|
+
"provides": ["server"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings service (`ctx.settings`): per-namespace sections kept in one YAML file in the app's home.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from ACRYL (`plugins/acryl-settings`). Plugins register a namespace with a schemastery schema and read the resolved value,
|
|
5
|
+
* which layers schema defaults, the registrant's `base`, and the stored section, in that order. Writes are validated, atomic (write a
|
|
6
|
+
* temporary file, then rename) and run one at a time in commit order; watchers see them in that order; an event announces each change.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import { type Context, Service } from '@deepseek-ai/cordis'
|
|
11
|
+
import z from '@deepseek-ai/schemastery'
|
|
12
|
+
import { parseDocument, stringify } from 'yaml'
|
|
13
|
+
import type {} from '../instance/index.ts'
|
|
14
|
+
import { cloneJsonShaped, deepEqualJson, isPlainObject, mergeLayers } from './merge.ts'
|
|
15
|
+
|
|
16
|
+
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/
|
|
17
|
+
export const DEFAULT_FILENAME = 'settings.yaml'
|
|
18
|
+
|
|
19
|
+
/** When a namespace's changes take effect for its owner. */
|
|
20
|
+
export type SettingsApplies = 'live' | 'restart'
|
|
21
|
+
|
|
22
|
+
export interface SettingsRegisterOptions<T> {
|
|
23
|
+
/** Values resolved below the stored layer (for example, what the app's composition chose). */
|
|
24
|
+
base?: Partial<T>
|
|
25
|
+
/** When the owner applies a change; shown to settings screens. Defaults to `live`. */
|
|
26
|
+
applies?: SettingsApplies
|
|
27
|
+
/** Reject a resolved value the schema cannot express as invalid; a throw refuses the write that produced it. */
|
|
28
|
+
validate?: (value: T) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One registered namespace, as a settings screen sees it. */
|
|
32
|
+
export interface SettingsDescriptor {
|
|
33
|
+
readonly namespace: string
|
|
34
|
+
/** The schemastery schema, serialized. */
|
|
35
|
+
readonly schema: unknown
|
|
36
|
+
readonly value: unknown
|
|
37
|
+
/** Bumps on every committed write. */
|
|
38
|
+
readonly revision: number
|
|
39
|
+
readonly applies: SettingsApplies
|
|
40
|
+
/** The stored section, when there is one. */
|
|
41
|
+
readonly user?: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The owner's handle on one namespace. */
|
|
45
|
+
export interface SettingsScope<T> {
|
|
46
|
+
/** Current resolved value: schema defaults, then `base`, then the stored section. */
|
|
47
|
+
get(): T
|
|
48
|
+
/** Observe committed changes; callbacks run one at a time in commit order. Returns the function that stops watching. */
|
|
49
|
+
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
|
|
50
|
+
/** Merge a patch into the stored section and persist it. */
|
|
51
|
+
update(patch: object): Promise<void>
|
|
52
|
+
/** Replace the stored section; `replace({})` resets every key to its base or default. */
|
|
53
|
+
replace(section: object): Promise<void>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SettingsConfig {
|
|
57
|
+
/** Absolute path of the YAML file; empty or absent means `<app home>/settings.yaml`. */
|
|
58
|
+
filename?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
declare module '@deepseek-ai/cordis' {
|
|
62
|
+
interface Context {
|
|
63
|
+
settings: Settings
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Events {
|
|
67
|
+
/** A committed change to one namespace's resolved value, after the file was written; never for an equal value. */
|
|
68
|
+
'settings/updated'(namespace: string, next: unknown, prev: unknown): void
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Entry {
|
|
73
|
+
readonly namespace: string
|
|
74
|
+
readonly schema: z<unknown>
|
|
75
|
+
readonly options: SettingsRegisterOptions<unknown>
|
|
76
|
+
resolved: unknown
|
|
77
|
+
revision: number
|
|
78
|
+
readonly watchers: Set<Watcher>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Watcher {
|
|
82
|
+
readonly callback: (next: unknown, prev: unknown) => void | Promise<void>
|
|
83
|
+
active: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertNamespace(value: string): void {
|
|
87
|
+
if (!NAMESPACE_PATTERN.test(value))
|
|
88
|
+
throw new TypeError(`settings: namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readDocument(filename: string): Record<string, unknown> {
|
|
92
|
+
if (!existsSync(filename)) return {}
|
|
93
|
+
const parsed = parseDocument(readFileSync(filename, 'utf8'), { prettyErrors: true })
|
|
94
|
+
if (parsed.errors.length > 0) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`settings: invalid settings file ${filename}: ${parsed.errors.map((error) => error.message).join('; ')}`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
const value: unknown = parsed.toJS() ?? {}
|
|
100
|
+
if (!isPlainObject(value))
|
|
101
|
+
throw new Error(`settings: ${filename} must be a map of namespace sections`)
|
|
102
|
+
return value
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The service. One instance serves one app home. */
|
|
106
|
+
export class Settings extends Service {
|
|
107
|
+
readonly filename: string
|
|
108
|
+
private document: Record<string, unknown>
|
|
109
|
+
private readonly entries = new Map<string, Entry>()
|
|
110
|
+
/** Writes and watcher notifications run one at a time, in commit order. */
|
|
111
|
+
private tail: Promise<void> = Promise.resolve()
|
|
112
|
+
private disposed = false
|
|
113
|
+
|
|
114
|
+
constructor(ctx: Context, config: SettingsConfig = {}) {
|
|
115
|
+
super(ctx, 'settings')
|
|
116
|
+
this.filename = config.filename ? config.filename : join(ctx.appInstance.home, DEFAULT_FILENAME)
|
|
117
|
+
this.document = readDocument(this.filename)
|
|
118
|
+
ctx.effect(
|
|
119
|
+
() => async () => {
|
|
120
|
+
this.disposed = true
|
|
121
|
+
await this.tail
|
|
122
|
+
},
|
|
123
|
+
'settings: settle pending writes',
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Register a namespace and return its handle. A stored section that fails the schema rejects the registration. */
|
|
128
|
+
register<T>(
|
|
129
|
+
namespace: string,
|
|
130
|
+
schema: z<T>,
|
|
131
|
+
options: SettingsRegisterOptions<T> = {},
|
|
132
|
+
): SettingsScope<T> {
|
|
133
|
+
assertNamespace(namespace)
|
|
134
|
+
if (this.entries.has(namespace))
|
|
135
|
+
throw new Error(`settings: namespace "${namespace}" is already registered`)
|
|
136
|
+
const entry: Entry = {
|
|
137
|
+
namespace,
|
|
138
|
+
schema: schema as z<unknown>,
|
|
139
|
+
options: options as SettingsRegisterOptions<unknown>,
|
|
140
|
+
resolved: undefined,
|
|
141
|
+
revision: 0,
|
|
142
|
+
watchers: new Set(),
|
|
143
|
+
}
|
|
144
|
+
entry.resolved = this.resolve(entry, this.section(namespace))
|
|
145
|
+
this.entries.set(namespace, entry)
|
|
146
|
+
this.ctx.effect(
|
|
147
|
+
() => () => void this.entries.delete(namespace),
|
|
148
|
+
`settings: namespace ${namespace}`,
|
|
149
|
+
)
|
|
150
|
+
return this.scopeFor<T>(entry)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The resolved value of a registered namespace, or `undefined` when nobody registered it. */
|
|
154
|
+
get(namespace: string): unknown {
|
|
155
|
+
return this.entries.get(namespace)?.resolved
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Merge a patch into a namespace by name (for a settings route that never held the scope). An unregistered namespace is refused. */
|
|
159
|
+
update(namespace: string, patch: Readonly<Record<string, unknown>>): Promise<void> {
|
|
160
|
+
const entry = this.entries.get(namespace)
|
|
161
|
+
if (entry === undefined)
|
|
162
|
+
return Promise.reject(new Error(`settings: namespace "${namespace}" is not registered`))
|
|
163
|
+
return this.commit(entry, (current) => mergeLayers(current, cloneJsonShaped({ ...patch })))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Every registered namespace, for settings screens. */
|
|
167
|
+
describe(): SettingsDescriptor[] {
|
|
168
|
+
return [...this.entries.values()].map((entry) => {
|
|
169
|
+
const stored = this.document[entry.namespace]
|
|
170
|
+
return {
|
|
171
|
+
namespace: entry.namespace,
|
|
172
|
+
schema: entry.schema.toJSON(),
|
|
173
|
+
value: entry.resolved,
|
|
174
|
+
revision: entry.revision,
|
|
175
|
+
applies: entry.options.applies ?? 'live',
|
|
176
|
+
...(stored === undefined ? {} : { user: structuredClone(stored) }),
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private section(namespace: string): Record<string, unknown> {
|
|
182
|
+
const stored = this.document[namespace]
|
|
183
|
+
if (stored === undefined) return {}
|
|
184
|
+
if (!isPlainObject(stored))
|
|
185
|
+
throw new Error(`settings: section "${namespace}" in ${this.filename} must be a map`)
|
|
186
|
+
return stored
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private resolve(entry: Entry, user: Record<string, unknown>): unknown {
|
|
190
|
+
const base = (entry.options.base ?? {}) as Record<string, unknown>
|
|
191
|
+
const value = entry.schema(mergeLayers(base, user))
|
|
192
|
+
entry.options.validate?.(value)
|
|
193
|
+
return value
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private scopeFor<T>(entry: Entry): SettingsScope<T> {
|
|
197
|
+
return {
|
|
198
|
+
get: () => entry.resolved as T,
|
|
199
|
+
watch: (callback) => {
|
|
200
|
+
const watcher: Watcher = { callback: callback as Watcher['callback'], active: true }
|
|
201
|
+
entry.watchers.add(watcher)
|
|
202
|
+
return () => {
|
|
203
|
+
watcher.active = false
|
|
204
|
+
entry.watchers.delete(watcher)
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
update: (patch) =>
|
|
208
|
+
this.commit(entry, (current) =>
|
|
209
|
+
mergeLayers(current, cloneJsonShaped(patch as Record<string, unknown>)),
|
|
210
|
+
),
|
|
211
|
+
replace: (section) =>
|
|
212
|
+
this.commit(entry, () => cloneJsonShaped(section as Record<string, unknown>)),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** One write: compute, validate, persist atomically, then notify. A refused write changes nothing. */
|
|
217
|
+
private commit(
|
|
218
|
+
entry: Entry,
|
|
219
|
+
next: (current: Record<string, unknown>) => Record<string, unknown>,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
const run = async (): Promise<void> => {
|
|
222
|
+
if (this.disposed) throw new Error('settings: the service is disposed')
|
|
223
|
+
const nextSection = next(this.section(entry.namespace))
|
|
224
|
+
const nextValue = this.resolve(entry, nextSection)
|
|
225
|
+
const prevValue = entry.resolved
|
|
226
|
+
const nextDocument = { ...this.document, [entry.namespace]: nextSection }
|
|
227
|
+
if (Object.keys(nextSection).length === 0) delete nextDocument[entry.namespace]
|
|
228
|
+
this.persist(nextDocument)
|
|
229
|
+
this.document = nextDocument
|
|
230
|
+
entry.revision += 1
|
|
231
|
+
entry.resolved = nextValue
|
|
232
|
+
if (deepEqualJson(prevValue, nextValue)) return
|
|
233
|
+
this.ctx.emit('settings/updated', entry.namespace, nextValue, prevValue)
|
|
234
|
+
for (const watcher of [...entry.watchers]) {
|
|
235
|
+
if (!watcher.active) continue
|
|
236
|
+
try {
|
|
237
|
+
await watcher.callback(nextValue, prevValue)
|
|
238
|
+
} catch (cause) {
|
|
239
|
+
this.ctx.logger?.error?.(
|
|
240
|
+
`settings: watcher for "${entry.namespace}" failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const result = this.tail.then(run, run)
|
|
246
|
+
this.tail = result.then(
|
|
247
|
+
() => undefined,
|
|
248
|
+
() => undefined,
|
|
249
|
+
)
|
|
250
|
+
return result
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private persist(document: Record<string, unknown>): void {
|
|
254
|
+
mkdirSync(dirname(this.filename), { recursive: true })
|
|
255
|
+
const temporary = `${this.filename}.${String(process.pid)}.tmp`
|
|
256
|
+
writeFileSync(temporary, stringify(document), { mode: 0o600 })
|
|
257
|
+
renameSync(temporary, this.filename)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The plugin: mount it after `instance`. */
|
|
262
|
+
export const settings = {
|
|
263
|
+
name: 'settings',
|
|
264
|
+
inject: ['appInstance'],
|
|
265
|
+
apply(ctx: Context, config: SettingsConfig) {
|
|
266
|
+
new Settings(ctx, config ?? {})
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export { z }
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Pure helpers for layering a user section over a base and for detaching JSON data. */
|
|
2
|
+
|
|
3
|
+
/** Whether a value is a plain data object (not an array, null, or class instance). */
|
|
4
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
5
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
6
|
+
const proto: unknown = Object.getPrototypeOf(value)
|
|
7
|
+
return proto === Object.prototype || proto === null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deep-merge `patch` over `base`: plain objects merge key by key, every other value (arrays included) replaces,
|
|
12
|
+
* and `undefined` entries in the patch are skipped.
|
|
13
|
+
*/
|
|
14
|
+
export function mergeLayers(
|
|
15
|
+
base: Record<string, unknown>,
|
|
16
|
+
patch: Record<string, unknown>,
|
|
17
|
+
): Record<string, unknown> {
|
|
18
|
+
const merged: Record<string, unknown> = { ...base }
|
|
19
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
20
|
+
if (value === undefined) continue
|
|
21
|
+
const current = merged[key]
|
|
22
|
+
merged[key] =
|
|
23
|
+
isPlainObject(current) && isPlainObject(value) ? mergeLayers(current, value) : value
|
|
24
|
+
}
|
|
25
|
+
return merged
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detach one write input and reject anything YAML would distort on a reload round trip: only plain objects, arrays,
|
|
30
|
+
* strings, finite numbers, booleans and `null` pass.
|
|
31
|
+
* @throws TypeError naming the `$`-rooted path of the first rejected value.
|
|
32
|
+
*/
|
|
33
|
+
export function cloneJsonShaped(root: Record<string, unknown>): Record<string, unknown> {
|
|
34
|
+
const visiting = new WeakSet<object>()
|
|
35
|
+
const clone = (value: unknown, path: string): unknown => {
|
|
36
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
|
37
|
+
if (typeof value === 'number') {
|
|
38
|
+
if (!Number.isFinite(value)) throw new TypeError(`settings: ${path} is a non-finite number`)
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
if (visiting.has(value)) throw new TypeError(`settings: ${path} is a circular reference`)
|
|
43
|
+
visiting.add(value)
|
|
44
|
+
const entries = value.map((entry, index) => clone(entry, `${path}[${String(index)}]`))
|
|
45
|
+
visiting.delete(value)
|
|
46
|
+
return entries
|
|
47
|
+
}
|
|
48
|
+
if (isPlainObject(value)) {
|
|
49
|
+
if (visiting.has(value)) throw new TypeError(`settings: ${path} is a circular reference`)
|
|
50
|
+
visiting.add(value)
|
|
51
|
+
const out: Record<string, unknown> = {}
|
|
52
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
53
|
+
if (entry === undefined) continue
|
|
54
|
+
out[key] = clone(entry, `${path}.${key}`)
|
|
55
|
+
}
|
|
56
|
+
visiting.delete(value)
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
throw new TypeError(
|
|
60
|
+
`settings: ${path} is ${value === undefined ? 'undefined' : `a ${typeof value}`}, which cannot be stored`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
return clone(root, '$') as Record<string, unknown>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Structural JSON equality for two detached values. */
|
|
67
|
+
export function deepEqualJson(a: unknown, b: unknown): boolean {
|
|
68
|
+
if (a === b) return true
|
|
69
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
70
|
+
return a.length === b.length && a.every((entry, index) => deepEqualJson(entry, b[index]))
|
|
71
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
72
|
+
const keys = Object.keys(a)
|
|
73
|
+
return (
|
|
74
|
+
keys.length === Object.keys(b).length &&
|
|
75
|
+
keys.every((key) => Object.hasOwn(b, key) && deepEqualJson(a[key], b[key]))
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "settings",
|
|
3
|
+
"description": "Validated settings per namespace in one YAML file in the app's home: defaults, base and stored layers, watch, update.",
|
|
4
|
+
"requires": ["instance"],
|
|
5
|
+
"inject": ["appInstance"],
|
|
6
|
+
"provides": ["settings"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
+
"@deepseek-ai/schemastery": "3.18.4",
|
|
10
|
+
"yaml": "2.9.0"
|
|
11
|
+
},
|
|
12
|
+
"mount": "mount it after instance: root.plugin(settings, {}); then ctx.settings.register('my-plugin', Schema)"
|
|
13
|
+
}
|