@fugood/buttress-server 2.26.0-beta.1 → 2.26.0-beta.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/README.md +52 -4
- package/config/function-samples/README.md +2 -0
- package/config/function-samples/bank-note.ts +47 -0
- package/config/function-samples/bank-watch-daemon.ts +63 -0
- package/lib/functions/bank-subscribe.d.ts +46 -0
- package/lib/functions/bank.d.ts +21 -0
- package/lib/functions/daemons.d.ts +45 -0
- package/lib/functions/executor.d.ts +25 -4
- package/lib/functions/index.d.ts +14 -6
- package/lib/functions/registry.d.ts +7 -1
- package/lib/functions/status.d.ts +48 -0
- package/lib/functions/templates.d.ts +3 -1
- package/lib/functions/types.d.ts +121 -0
- package/lib/index.mjs +254 -42
- package/lib/routes/anthropic-messages.d.ts +2 -2
- package/lib/routes/openai-compat.d.ts +2 -2
- package/lib/utils/cors.check.d.ts +1 -0
- package/lib/utils/cors.d.ts +72 -0
- package/lib/utils/workspaceState.d.ts +9 -0
- package/package.json +2 -2
- package/public/status.html +77 -1
package/README.md
CHANGED
|
@@ -653,9 +653,9 @@ dir = "./functions" # relative paths resolve against this config file
|
|
|
653
653
|
|
|
654
654
|
`ENABLE_FUNCTIONS_ENDPOINT=1` and `BUTTRESS_FUNCTIONS_DIR=<dir>` are equivalent to the first two keys.
|
|
655
655
|
|
|
656
|
-
On startup the server creates the directory if needed and writes `buttress-functions.d.ts` (ambient types, refreshed every start), plus a `tsconfig.json` and
|
|
656
|
+
On startup the server creates the directory if needed and writes `buttress-functions.d.ts` (ambient types, refreshed every start), plus a `tsconfig.json` and commented `_example.ts` / `_example-daemon.ts` files when the directory holds no functions yet.
|
|
657
657
|
|
|
658
|
-
Ready-to-copy examples — a no-prerequisite starter, LLM summarization, sqlite-vec RAG, ffmpeg + STT transcription, TTS with a downloadable result, and a custom auth gate — live in [`config/function-samples/`](config/function-samples/).
|
|
658
|
+
Ready-to-copy examples — a no-prerequisite starter, LLM summarization, sqlite-vec RAG, ffmpeg + STT transcription, TTS with a downloadable result, a remote Data Bank read/write, a Data Bank–watching daemon, and a custom auth gate — live in [`config/function-samples/`](config/function-samples/).
|
|
659
659
|
|
|
660
660
|
### Writing a function
|
|
661
661
|
|
|
@@ -693,6 +693,8 @@ export default async function ({ path }: { path: string }, context: ButtressFunc
|
|
|
693
693
|
| `buttress.detokenize({ model?, tokens })` | Convert token ids back into text with the same model. |
|
|
694
694
|
| `buttress.transcribe({ model?, filePath \| audioData, options? })` | Transcribe audio with this server's STT generator. |
|
|
695
695
|
| `buttress.synthesize({ model?, text, options? })` | Synthesize speech with this server's TTS generator (`onnx-tts` or `ggml-tts`); the WAV lands in `tempDir` → `{ path, sampling_rate, channels }`. |
|
|
696
|
+
| `bank.list({ keyword?, meta?, ids? })` / `bank.get(id)` / `bank.update(properties, { dontNotify? })` / `bank.remove(id)` | Read/write the bound workspace's remote **Data Bank** (see below). To listen for changes, use `bank.subscribe` inside a daemon function. |
|
|
697
|
+
| `daemons.emit(name, event, data?)` / `daemons.list()` | Signal a running **daemon function**'s `onEvent` handlers (see below) / list running daemon names. |
|
|
696
698
|
| `emit(event, data)` | Progress event; delivered to SSE callers, ignored otherwise. |
|
|
697
699
|
| `signal` | `AbortSignal`, aborted on timeout or caller disconnect. |
|
|
698
700
|
| `tempDir` | Per-call scratch directory, created on first access. |
|
|
@@ -700,6 +702,52 @@ export default async function ({ path }: { path: string }, context: ButtressFunc
|
|
|
700
702
|
| `log`, `fetch`, `env`, `config`, `dir` | Prefixed logging, host `fetch`, `process.env`, the `[functions.config]` table, the functions directory. |
|
|
701
703
|
| `libs` | `_`/`lodash`, `moment`, `math`/`mathjs`, `voca`, `chroma`, `json5`, `qs`, `bytes`, `ms`, `nanoid`, `md5`. |
|
|
702
704
|
|
|
705
|
+
#### Remote Data Bank (`context.bank`)
|
|
706
|
+
|
|
707
|
+
A bound server can read and write the workspace's remote **Data Bank** — the same property space the `bricks data` CLI commands and remote-update apps use. Credentials are issued through the Workspace API by the CLI and stored next to the workspace binding:
|
|
708
|
+
|
|
709
|
+
```bash
|
|
710
|
+
bricks buttress bank-key # on the server host, from a workspace-authed CLI
|
|
711
|
+
# then restart buttress-server
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
This writes a `bank` entry (Data Bank endpoint + spacename + space key) into `~/.bricks-cli/buttress/state.json`. Until it exists, every `context.bank` method throws with a hint. `bricks buttress bank-key --revoke` revokes the key and removes the entry; re-running `bank-key` rotates it.
|
|
715
|
+
|
|
716
|
+
`context.bank` is read/write; to *react* to Data Bank changes, use `bank.subscribe` inside a daemon function (below) — it throws elsewhere. Mind one Bank semantic: `update` **replaces `value` unconditionally** (an omitted `value` clears the stored one), so read-merge first when only part of an object should change. Updates notify subscribed devices unless you pass `{ dontNotify: true }`. See `config/function-samples/bank-note.ts` for the end-to-end shape.
|
|
717
|
+
|
|
718
|
+
#### Daemon functions (`meta.daemon`)
|
|
719
|
+
|
|
720
|
+
A file with `meta.daemon = true` runs as a **daemon**: a long-lived background function. Its default export runs **once** when the daemon starts — with a `ButtressDaemonContext` as its only argument — and everything it registers there keeps running after it returns. Daemons are not MCP tools and cannot be called over HTTP (a direct call returns `400 FUNCTION_NOT_CALLABLE`); `GET /functions` lists them in a separate `daemons` array. `meta.timeout` does not apply — a daemon has no deadline.
|
|
721
|
+
|
|
722
|
+
```ts
|
|
723
|
+
export const meta: ButtressFunctionMeta = {
|
|
724
|
+
description: 'Watch the Data Bank and keep a summary fresh',
|
|
725
|
+
daemon: true,
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export default async function (context: ButtressDaemonContext) {
|
|
729
|
+
context.setInterval(async () => {
|
|
730
|
+
// periodic work; an error here is logged, the timer keeps firing
|
|
731
|
+
}, 5 * 60 * 1000)
|
|
732
|
+
|
|
733
|
+
context.bank.subscribe(['orders', 'menu'], async (properties) => {
|
|
734
|
+
// remote Data Bank changes to the watched property ids
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
context.onEvent(async ({ event, data, source }) => {
|
|
738
|
+
// another local function called context.daemons.emit('<this-file>', event, data)
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
return () => {
|
|
742
|
+
// optional cleanup, runs when the daemon stops
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
The daemon context is the full function context plus lifetime APIs. `setInterval(cb, ms)` / `clearInterval(handle)` are managed timers: a callback that throws is logged (and recorded on `/status`) but never stops the timer or the daemon; everything is cleared when the daemon stops. `bank.subscribe(propertyIds, onChange)` uses the same Bank credentials as `context.bank` (`bricks buttress bank-key`; throws until stored); all subscriptions share one auto-reconnecting connection, so a network drop never stops the daemon — note a daemon that writes the properties it watches also notifies itself unless it passes `{ dontNotify: true }`. `onEvent(handler)` receives what other local functions (and daemons) send with `context.daemons.emit(name, event, data?)`, as `{ event, data, source }`; `emit` throws when the target daemon is not running or registered no handler, and events emitted while a daemon is still starting are buffered until it settles.
|
|
748
|
+
|
|
749
|
+
The handler runs with no deadline; `context.signal` aborts when the daemon stops. A file edit restarts it (cleanup → fresh module → start) — on save with `hot_reload = true`, otherwise on the periodic (~60s) reconcile sweep. A start invocation that throws, or a file that stops loading, suspends the daemon (shown as an error on `/status`) until the file changes again. On stop, timers and subscriptions are torn down, spawned children are killed, and the returned cleanup gets a bounded (~10s) run. `context.emit` (SSE progress) is a no-op inside a daemon — there is no caller; use `context.log` and the status page.
|
|
750
|
+
|
|
703
751
|
Functions may `import` Node builtins (`node:fs/promises`, …), sibling files inside the functions directory, and the two provided database packages `sqlite3` and `sqlite-vec`. Other package imports are rejected. `sqlite-vec` supports macOS and Linux on x64/arm64, plus Windows x64; its upstream package does not ship a Windows arm64 extension, so the standalone Windows arm64 build does not provide either SQLite import.
|
|
704
752
|
|
|
705
753
|
Edits are picked up on the next call — the server re-transpiles when a file in the function's module graph changes, so no restart is needed. A file that fails to load is logged and skipped; the rest keep working.
|
|
@@ -718,7 +766,7 @@ For faster authoring feedback, opt into eager reloading with `[functions] hot_re
|
|
|
718
766
|
| `GET /functions/files/<path>` | Download a file a function wrote to its `tempDir` — functions hand out these URLs via `context.fileUrl` |
|
|
719
767
|
| `POST /functions/upload` | Stage an input file on the server (multipart, `file` field) → `{ "path", "url", "name", "size" }` |
|
|
720
768
|
|
|
721
|
-
Errors come back as `{ "error": { "code", "message" } }` with `FUNCTION_NOT_FOUND` (404), `FUNCTION_TIMEOUT` (504), `FUNCTION_FAILED` (500) or `FUNCTION_FILE_NOT_FOUND` (404).
|
|
769
|
+
Errors come back as `{ "error": { "code", "message" } }` with `FUNCTION_NOT_FOUND` (404), `FUNCTION_NOT_CALLABLE` (400, the name is a daemon), `FUNCTION_TIMEOUT` (504), `FUNCTION_FAILED` (500) or `FUNCTION_FILE_NOT_FOUND` (404).
|
|
722
770
|
|
|
723
771
|
#### Calling with GET
|
|
724
772
|
|
|
@@ -752,7 +800,7 @@ curl -X POST <base>/functions/transcribe-media -F file=@interview.mp4
|
|
|
752
800
|
|
|
753
801
|
To stage a file once and reuse it across calls, `curl -F file=@interview.mp4 <base>/functions/upload` stores it in its own scratch directory and returns `{ "path", "url", "name", "size" }` — pass `path` as the function's input. Either way the client file name is sanitized to a bare name, and staged files share the auth guard and the ~24h sweep. Requests are capped by `[server] max_body_size` (default 50MB) — raise it for large media.
|
|
754
802
|
|
|
755
|
-
All of this activity is observable: the `/status` dashboard (and the `/buttress/status` JSON it polls) carries a **Local Functions** card with counters since startup and recent history for calls (per surface: HTTP/SSE/MCP, with durations and failure reasons), uploads, downloads, and auth decisions (allowed/denied with mode and subject — never credentials).
|
|
803
|
+
All of this activity is observable: the `/status` dashboard (and the `/buttress/status` JSON it polls) carries a **Local Functions** card with counters since startup and recent history for calls (per surface: HTTP/SSE/MCP, with durations and failure reasons), daemon callback runs, uploads, downloads, and auth decisions (allowed/denied with mode and subject — never credentials). Daemons additionally get a live table: running/error state, start time, active timers, Bank subscription health, listening flag, and callback run/failure counts.
|
|
756
804
|
|
|
757
805
|
To point an agent at the MCP endpoint:
|
|
758
806
|
|
|
@@ -14,6 +14,8 @@ needed. On first start the server scaffolds `buttress-functions.d.ts` and a
|
|
|
14
14
|
| `simple-rag.ts` | Token chunking, embeddings, `sqlite3` + `sqlite-vec` retrieval, then completion | a chat LLM + a GGML embedding `[[generators]]` entry (below) |
|
|
15
15
|
| `transcribe-media.ts` | `context.spawn` (ffmpeg), the scratch dir, SSE progress, STT | `ffmpeg` on PATH + an STT `[[generators]]` entry |
|
|
16
16
|
| `text-to-speech.ts` | TTS (`context.buttress.synthesize`) + downloadable output (`context.fileUrl`) | an `onnx-tts` `[[generators]]` entry |
|
|
17
|
+
| `bank-note.ts` | Reading/writing the workspace's remote Data Bank (`context.bank`) | `bricks buttress bank-key` run on this host |
|
|
18
|
+
| `bank-watch-daemon.ts` | A daemon (`meta.daemon = true`): `context.bank.subscribe`, `context.setInterval`, `context.onEvent` | `bricks buttress bank-key` run on this host |
|
|
17
19
|
| `_auth.ts` | Custom auth: keep workspace tokens working, add static API keys | see the file header |
|
|
18
20
|
|
|
19
21
|
`_auth.ts` is not a function: copying it changes how every `/functions` endpoint
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Read and write a property in the workspace's remote Data Bank.
|
|
2
|
+
//
|
|
3
|
+
// Needs stored Data Bank credentials: run `bricks buttress bank-key` from a
|
|
4
|
+
// workspace-authed CLI on this host, then restart the server. Without them
|
|
5
|
+
// every `context.bank` method throws.
|
|
6
|
+
//
|
|
7
|
+
// curl -X POST http://<host>:<port>/functions/bank-note \
|
|
8
|
+
// -H 'content-type: application/json' \
|
|
9
|
+
// -d '{"propertyId": "shared-note", "text": "hello from buttress"}'
|
|
10
|
+
//
|
|
11
|
+
// Omit `text` to read the property without writing.
|
|
12
|
+
|
|
13
|
+
export const meta: ButtressFunctionMeta = {
|
|
14
|
+
description: 'Read a Data Bank property, optionally replacing its text value first',
|
|
15
|
+
parameters: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
propertyId: { type: 'string', description: 'Data Bank property id' },
|
|
19
|
+
text: { type: 'string', description: 'New value; omit to only read' },
|
|
20
|
+
},
|
|
21
|
+
required: ['propertyId'],
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default async function (
|
|
26
|
+
{ propertyId, text }: { propertyId: string; text?: string },
|
|
27
|
+
context: ButtressFunctionContext,
|
|
28
|
+
) {
|
|
29
|
+
if (text != null) {
|
|
30
|
+
// `update` replaces the stored value unconditionally (an omitted `value`
|
|
31
|
+
// clears it) — read-merge first when only part of an object should change.
|
|
32
|
+
await context.bank.update([
|
|
33
|
+
{ propertyId, value: text, updateNote: 'Updated via bank-note function' },
|
|
34
|
+
])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const property = await context.bank.get(propertyId)
|
|
38
|
+
if (!property) return { propertyId, exists: false }
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
propertyId,
|
|
42
|
+
exists: true,
|
|
43
|
+
value: property.value,
|
|
44
|
+
updateAt: property.updateAt,
|
|
45
|
+
lastUpdateNote: property.lastUpdateNote,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Daemon: watch Data Bank properties and keep a change journal.
|
|
2
|
+
//
|
|
3
|
+
// A file with `meta.daemon = true` runs as a daemon — a long-lived
|
|
4
|
+
// background function. Its default export runs ONCE when the daemon starts,
|
|
5
|
+
// and everything registered on the context keeps running after it returns.
|
|
6
|
+
// Daemons are not MCP tools and cannot be called over HTTP; their live
|
|
7
|
+
// status shows on the /status page.
|
|
8
|
+
//
|
|
9
|
+
// This one reacts to remote Data Bank changes (needs stored credentials —
|
|
10
|
+
// run `bricks buttress bank-key` on this host, then restart the server),
|
|
11
|
+
// flushes hourly, and accepts a `flush` event from other local functions:
|
|
12
|
+
//
|
|
13
|
+
// context.daemons.emit('bank-watch-daemon', 'flush')
|
|
14
|
+
|
|
15
|
+
export const meta: ButtressFunctionMeta = {
|
|
16
|
+
description: 'Journal changes to watched Data Bank properties',
|
|
17
|
+
daemon: true,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default async function (context: ButtressDaemonContext) {
|
|
21
|
+
let journal: { at: string; propertyId: string; value: unknown }[] = []
|
|
22
|
+
|
|
23
|
+
const flush = async () => {
|
|
24
|
+
if (journal.length === 0) return
|
|
25
|
+
const entries = journal
|
|
26
|
+
journal = []
|
|
27
|
+
// `dontNotify` keeps this daemon from waking itself up with its own write.
|
|
28
|
+
await context.bank.update([{ propertyId: 'shared-note-journal', value: entries }], {
|
|
29
|
+
dontNotify: true,
|
|
30
|
+
})
|
|
31
|
+
context.log(`flushed ${entries.length} journal entr${entries.length === 1 ? 'y' : 'ies'}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Changes to the watched ids arrive here over a shared, auto-reconnecting
|
|
35
|
+
// connection — a network drop never stops the daemon.
|
|
36
|
+
context.bank.subscribe(['shared-note'], (properties) => {
|
|
37
|
+
for (const property of properties) {
|
|
38
|
+
journal.push({
|
|
39
|
+
at: new Date().toISOString(),
|
|
40
|
+
propertyId: property.propertyId,
|
|
41
|
+
value: property.value,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
context.log(`journaled ${properties.length} change(s), ${journal.length} pending`)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// A managed setInterval: cleared automatically when the daemon stops, and
|
|
48
|
+
// a callback that throws is logged without stopping the timer.
|
|
49
|
+
context.setInterval(flush, 60 * 60 * 1000)
|
|
50
|
+
|
|
51
|
+
// Other local functions can force a flush via context.daemons.emit.
|
|
52
|
+
context.onEvent(({ event, source }) => {
|
|
53
|
+
if (event === 'flush') {
|
|
54
|
+
context.log(`flush requested by ${source}`)
|
|
55
|
+
return flush()
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
context.log('watching shared-note')
|
|
60
|
+
|
|
61
|
+
// Runs when the daemon stops (file edited/removed, or server shutdown).
|
|
62
|
+
return () => flush()
|
|
63
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Bank change subscriptions for daemon functions.
|
|
3
|
+
*
|
|
4
|
+
* The Bank's `/api/subscriptions` WebSocket speaks the *legacy* `graphql-ws`
|
|
5
|
+
* protocol (`connection_init` → `connection_ack`, `start` → `data`, `ka`
|
|
6
|
+
* keep-alives) — that is what the deployed juniper handler serves, and what
|
|
7
|
+
* every production client falls back to. The vocabulary is four message types,
|
|
8
|
+
* so this is a small hand-rolled client rather than a protocol library (the
|
|
9
|
+
* maintained `graphql-ws` npm package only speaks the newer
|
|
10
|
+
* `graphql-transport-ws` protocol, which the Bank does not).
|
|
11
|
+
*
|
|
12
|
+
* Auth rides on the upgrade request's query string (`?spacename=&spacekey=`),
|
|
13
|
+
* which the Bank accepts as an alternative to headers — the standard
|
|
14
|
+
* `WebSocket` global cannot set custom headers.
|
|
15
|
+
*
|
|
16
|
+
* One connection multiplexes every daemon's subscription (one `start` per
|
|
17
|
+
* daemon, ids routed on `data`). The socket opens when the first subscription
|
|
18
|
+
* is added, reconnects with backoff while any remain, and closes when the
|
|
19
|
+
* last one is removed. The Bank pushes `ka` every ~10s, so a silent socket is
|
|
20
|
+
* a dead one: a watchdog forces a reconnect when nothing arrives for a while.
|
|
21
|
+
*/
|
|
22
|
+
import type { BankBinding } from '../utils/workspaceState';
|
|
23
|
+
import type { BankProperty } from './types';
|
|
24
|
+
export type BankSubscriberStatus = 'idle' | 'connecting' | 'connected' | 'disconnected';
|
|
25
|
+
export type BankSubscription = {
|
|
26
|
+
close: () => void;
|
|
27
|
+
};
|
|
28
|
+
export type BankSubscriberOptions = {
|
|
29
|
+
/** Injectable for tests; defaults to the global WebSocket. */
|
|
30
|
+
webSocketImpl?: typeof WebSocket;
|
|
31
|
+
/** Injectable timer fns for tests. */
|
|
32
|
+
setTimeoutFn?: typeof setTimeout;
|
|
33
|
+
clearTimeoutFn?: typeof clearTimeout;
|
|
34
|
+
};
|
|
35
|
+
/** Build the legacy-protocol subscriptions URL for a Bank binding. */
|
|
36
|
+
export declare const bankSubscriptionsUrl: (binding: BankBinding) => string;
|
|
37
|
+
export type BankSubscriber = ReturnType<typeof createBankSubscriber>;
|
|
38
|
+
export declare const createBankSubscriber: (binding: BankBinding, { webSocketImpl, setTimeoutFn, clearTimeoutFn, }?: BankSubscriberOptions) => {
|
|
39
|
+
status: () => BankSubscriberStatus;
|
|
40
|
+
/**
|
|
41
|
+
* Watch a set of property ids. `onProperties` receives each change batch;
|
|
42
|
+
* `onStatus` follows the shared connection's health.
|
|
43
|
+
*/
|
|
44
|
+
subscribe(filteredProps: string[], onProperties: (properties: BankProperty[]) => void, onStatus: (status: BankSubscriberStatus) => void): BankSubscription;
|
|
45
|
+
dispose(): void;
|
|
46
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context.bank` — remote Data Bank access for local functions.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the public Data Bank GraphQL API (SPACENAME/SPACEKEY headers) with
|
|
5
|
+
* credentials issued by `bricks buttress bank-key` and stored in the buttress
|
|
6
|
+
* state file. Read/write only in v1 — no subscription surface. Mirrors the
|
|
7
|
+
* client in packages/bricks-cli/src/utils/bank-client.js.
|
|
8
|
+
*/
|
|
9
|
+
import type { BankContext, BankProperty } from './types';
|
|
10
|
+
import type { BankBinding } from '../utils/workspaceState';
|
|
11
|
+
export declare const PROPERTY_FIELDS = "\n propertyId\n meta\n definition\n value\n tags\n lastUpdateHash\n lastUpdateNote\n lastUpdateKey\n createAt\n updateAt\n";
|
|
12
|
+
export declare const normalizeProperty: (property: any) => BankProperty;
|
|
13
|
+
export declare class BankNotConfiguredError extends Error {
|
|
14
|
+
constructor();
|
|
15
|
+
}
|
|
16
|
+
type CreateBankContextOptions = {
|
|
17
|
+
/** Ends in-flight Bank requests when the call is aborted or times out. */
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
};
|
|
20
|
+
export declare const createBankContext: (binding: BankBinding | null | undefined, { signal }: CreateBankContextOptions) => BankContext;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon functions: long-lived local functions.
|
|
3
|
+
*
|
|
4
|
+
* A file with `meta.daemon = true` is a daemon. Its default export runs
|
|
5
|
+
* **once** when the daemon starts, with a `DaemonContext` — the ordinary
|
|
6
|
+
* function context plus lifetime APIs — and everything it registers there
|
|
7
|
+
* keeps running after the invocation returns:
|
|
8
|
+
*
|
|
9
|
+
* - `context.setInterval(cb, ms)` / `context.clearInterval(handle)` —
|
|
10
|
+
* managed timers; a callback that throws is logged, never fatal.
|
|
11
|
+
* - `context.bank.subscribe(ids, onChange)` — remote Data Bank change
|
|
12
|
+
* notifications over one shared, auto-reconnecting connection.
|
|
13
|
+
* - `context.onEvent(handler)` — events other local functions send via
|
|
14
|
+
* `context.daemons.emit(name, event, data)`.
|
|
15
|
+
*
|
|
16
|
+
* No deadline applies to a daemon (`meta.timeout` is ignored). It stops when
|
|
17
|
+
* its file changes or disappears (the manager's reconcile — eager with the
|
|
18
|
+
* hot-reload watcher, a periodic sweep otherwise) or on server shutdown:
|
|
19
|
+
* `context.signal` aborts, timers and subscriptions are torn down, spawned
|
|
20
|
+
* children are killed, and a cleanup function returned by the handler (if
|
|
21
|
+
* any) gets a bounded run. A file edit then starts a fresh module.
|
|
22
|
+
*/
|
|
23
|
+
import { createBankSubscriber } from './bank-subscribe';
|
|
24
|
+
import type { FunctionsRegistry } from './registry';
|
|
25
|
+
import type { FunctionsStatusTracker } from './status';
|
|
26
|
+
import type { DaemonSummary, DaemonsHub, FunctionRuntime, FunctionsConfig } from './types';
|
|
27
|
+
export type CreateDaemonManagerOptions = {
|
|
28
|
+
registry: FunctionsRegistry;
|
|
29
|
+
runtime: FunctionRuntime;
|
|
30
|
+
functionsConfig: FunctionsConfig;
|
|
31
|
+
tracker?: FunctionsStatusTracker;
|
|
32
|
+
/** Injectable for tests; defaults to `createBankSubscriber`. */
|
|
33
|
+
subscriberFactory?: typeof createBankSubscriber;
|
|
34
|
+
reconcileIntervalMs?: number;
|
|
35
|
+
/** Called after every reconcile with the current daemon count. */
|
|
36
|
+
onReconciled?: (count: number) => void;
|
|
37
|
+
};
|
|
38
|
+
export type DaemonManager = ReturnType<typeof createDaemonManager>;
|
|
39
|
+
export declare const createDaemonManager: ({ registry, runtime, functionsConfig, tracker, subscriberFactory, reconcileIntervalMs, onReconciled, }: CreateDaemonManagerOptions) => {
|
|
40
|
+
hub: DaemonsHub;
|
|
41
|
+
reconcile: () => Promise<void>;
|
|
42
|
+
count: () => number;
|
|
43
|
+
list: () => DaemonSummary[];
|
|
44
|
+
dispose: () => Promise<void>;
|
|
45
|
+
};
|
|
@@ -8,20 +8,41 @@
|
|
|
8
8
|
* synchronously cannot be interrupted — function files are trusted, and vm has
|
|
9
9
|
* no way to preempt a running script.
|
|
10
10
|
*/
|
|
11
|
-
import type { FunctionEmit, FunctionRuntime, FunctionsConfig, LoadedFunction } from './types';
|
|
11
|
+
import type { DaemonsHub, FunctionContext, FunctionEmit, FunctionRuntime, FunctionsConfig, LoadedFunction } from './types';
|
|
12
12
|
export declare class FunctionTimeoutError extends Error {
|
|
13
13
|
constructor(name: string, timeoutMs: number);
|
|
14
14
|
}
|
|
15
15
|
export declare class FunctionAbortError extends Error {
|
|
16
16
|
constructor(name: string);
|
|
17
17
|
}
|
|
18
|
-
export type
|
|
18
|
+
export type CallContextOptions = {
|
|
19
19
|
runtime: FunctionRuntime;
|
|
20
20
|
functionsConfig: FunctionsConfig;
|
|
21
21
|
/** Streams progress to the caller; ignored on non-streaming surfaces. */
|
|
22
22
|
emit?: FunctionEmit;
|
|
23
|
+
callId?: string;
|
|
24
|
+
/** Routes `context.daemons`; absent when the daemon manager is not running. */
|
|
25
|
+
daemons?: DaemonsHub;
|
|
26
|
+
};
|
|
27
|
+
export type ExecuteOptions = CallContextOptions & {
|
|
23
28
|
/** Aborts the call early (e.g. the HTTP client disconnected). */
|
|
24
29
|
signal?: AbortSignal;
|
|
25
|
-
callId?: string;
|
|
26
30
|
};
|
|
27
|
-
|
|
31
|
+
/** A live function context plus the levers that end it. */
|
|
32
|
+
export type FunctionCallHandle = {
|
|
33
|
+
context: FunctionContext;
|
|
34
|
+
/** Aborted when the run is stopped or times out. */
|
|
35
|
+
signal: AbortSignal;
|
|
36
|
+
/** Abort the run and terminate every child process it spawned. */
|
|
37
|
+
abort: () => void;
|
|
38
|
+
/** Final cleanup: stop `emit` delivery and kill leftover children. */
|
|
39
|
+
finish: () => void;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Build the context a function runs against, without any deadline or
|
|
43
|
+
* lifecycle policy. `executeFunction` wraps this for ordinary calls (deadline
|
|
44
|
+
* + abort race); the daemon manager uses it directly for its long-lived
|
|
45
|
+
* runs, where the context must outlive the initial invocation.
|
|
46
|
+
*/
|
|
47
|
+
export declare const createCallContext: (fn: LoadedFunction, { runtime, functionsConfig, emit, callId, daemons }: CallContextOptions) => FunctionCallHandle;
|
|
48
|
+
export declare const executeFunction: (fn: LoadedFunction, input: any, { signal, ...contextOptions }: ExecuteOptions) => Promise<any>;
|
package/lib/functions/index.d.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* owns discovery/reload (registry) and execution (executor), and prepares the
|
|
7
7
|
* directory for authoring on startup.
|
|
8
8
|
*/
|
|
9
|
-
import type { FunctionEmit, FunctionRuntime, FunctionSummary, FunctionsConfig, LoadedAuthFunction } from './types';
|
|
9
|
+
import type { DaemonSummary, FunctionEmit, FunctionRuntime, FunctionSummary, FunctionsConfig, LoadedAuthFunction } from './types';
|
|
10
10
|
export { resolveFunctionsConfig } from './config';
|
|
11
|
-
export { FunctionNotFoundError } from './registry';
|
|
11
|
+
export { FunctionNotFoundError, FunctionNotCallableError } from './registry';
|
|
12
12
|
export { FunctionAbortError, FunctionTimeoutError } from './executor';
|
|
13
13
|
export { FunctionImportError } from './loader';
|
|
14
14
|
export { AUTH_BASENAME } from './auth';
|
|
@@ -25,6 +25,8 @@ export type FunctionsService = {
|
|
|
25
25
|
config: FunctionsConfig;
|
|
26
26
|
dir: string;
|
|
27
27
|
list: () => Promise<FunctionSummary[]>;
|
|
28
|
+
/** Daemons the manager is currently running (empty when daemons are off). */
|
|
29
|
+
listDaemons: () => DaemonSummary[];
|
|
28
30
|
/** One function's summary; rejects when it is unknown or fails to load. */
|
|
29
31
|
describe: (name: string) => Promise<FunctionSummary>;
|
|
30
32
|
call: (name: string, input: any, options: CallOptions) => Promise<any>;
|
|
@@ -35,18 +37,24 @@ export type FunctionsService = {
|
|
|
35
37
|
getCustomAuth: () => Promise<LoadedAuthFunction | null>;
|
|
36
38
|
/**
|
|
37
39
|
* Live capability summary for `serverInfo`. Mutated in place on every
|
|
38
|
-
* `list()`
|
|
39
|
-
* at whatever was on disk during startup.
|
|
40
|
+
* `list()` (and every daemon reconcile) so the announced counts follow the
|
|
41
|
+
* directory instead of freezing at whatever was on disk during startup.
|
|
40
42
|
*/
|
|
41
43
|
stats: {
|
|
42
44
|
enabled: true;
|
|
43
45
|
count: number;
|
|
46
|
+
daemons: number;
|
|
44
47
|
};
|
|
45
|
-
/** Stop the hot-reload watcher
|
|
48
|
+
/** Stop the hot-reload watcher and daemons. Safe to call always. */
|
|
46
49
|
dispose: () => void;
|
|
47
50
|
};
|
|
48
51
|
export type CreateFunctionsServiceOptions = {
|
|
49
52
|
/** `server.temp_file_dir`; per-call scratch space lives under it. */
|
|
50
53
|
tempFileDir: string;
|
|
54
|
+
/**
|
|
55
|
+
* Server-wide runtime for daemon invocations. Daemon files are inert (with
|
|
56
|
+
* a warning) when omitted — request surfaces still pass a runtime per call.
|
|
57
|
+
*/
|
|
58
|
+
runtime?: FunctionRuntime;
|
|
51
59
|
};
|
|
52
|
-
export declare const createFunctionsService: (config: FunctionsConfig, { tempFileDir }: CreateFunctionsServiceOptions) => Promise<FunctionsService>;
|
|
60
|
+
export declare const createFunctionsService: (config: FunctionsConfig, { tempFileDir, runtime }: CreateFunctionsServiceOptions) => Promise<FunctionsService>;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* re-transpiled into a fresh vm context. No watchers, identical behavior on
|
|
11
11
|
* every platform, and an edit is picked up by the very next call.
|
|
12
12
|
*/
|
|
13
|
-
import type { FunctionSummary, FunctionsConfig, LoadedFunction } from './types';
|
|
13
|
+
import type { DaemonSummary, FunctionSummary, FunctionsConfig, LoadedFunction } from './types';
|
|
14
14
|
type Entry = {
|
|
15
15
|
name: string;
|
|
16
16
|
file: string;
|
|
@@ -22,6 +22,9 @@ type Entry = {
|
|
|
22
22
|
export declare class FunctionNotFoundError extends Error {
|
|
23
23
|
constructor(name: string);
|
|
24
24
|
}
|
|
25
|
+
export declare class FunctionNotCallableError extends Error {
|
|
26
|
+
constructor(name: string);
|
|
27
|
+
}
|
|
25
28
|
export type FunctionsRegistry = ReturnType<typeof createFunctionsRegistry>;
|
|
26
29
|
export declare const createFunctionsRegistry: (config: FunctionsConfig) => {
|
|
27
30
|
dir: string;
|
|
@@ -30,6 +33,9 @@ export declare const createFunctionsRegistry: (config: FunctionsConfig) => {
|
|
|
30
33
|
get: (name: string) => Promise<LoadedFunction>;
|
|
31
34
|
describe: (name: string) => Promise<FunctionSummary>;
|
|
32
35
|
list: () => Promise<FunctionSummary[]>;
|
|
36
|
+
listDaemons: () => Promise<LoadedFunction[]>;
|
|
33
37
|
entries: Map<string, Entry>;
|
|
34
38
|
};
|
|
39
|
+
/** A daemon's public shape, as reported by `GET /functions` and the status page. */
|
|
40
|
+
export declare const toDaemonSummary: (fn: LoadedFunction) => DaemonSummary;
|
|
35
41
|
export {};
|
|
@@ -18,6 +18,36 @@ export type FunctionCallRecord = {
|
|
|
18
18
|
success: boolean;
|
|
19
19
|
error?: string;
|
|
20
20
|
};
|
|
21
|
+
export type DaemonActivityRecord = {
|
|
22
|
+
timestamp: string;
|
|
23
|
+
name: string;
|
|
24
|
+
/** Which daemon callback ran ('start', 'interval', 'bank', 'event', 'stop'). */
|
|
25
|
+
event: string;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
success: boolean;
|
|
28
|
+
error?: string;
|
|
29
|
+
};
|
|
30
|
+
/** Live state of one daemon, provided by the daemon manager. */
|
|
31
|
+
export type DaemonStateSnapshot = {
|
|
32
|
+
name: string;
|
|
33
|
+
state: 'running' | 'error';
|
|
34
|
+
description: string;
|
|
35
|
+
startedAt: string | null;
|
|
36
|
+
/** Active `context.setInterval` timers. */
|
|
37
|
+
timers: number;
|
|
38
|
+
/** Active `context.bank.subscribe` subscriptions. */
|
|
39
|
+
bankSubscriptions: number;
|
|
40
|
+
/** Data Bank connection health; null while the daemon holds no subscriptions. */
|
|
41
|
+
bank: 'connected' | 'connecting' | 'disconnected' | null;
|
|
42
|
+
/** Whether the daemon registered a `context.onEvent` handler. */
|
|
43
|
+
listening: boolean;
|
|
44
|
+
counts: {
|
|
45
|
+
runs: number;
|
|
46
|
+
failed: number;
|
|
47
|
+
};
|
|
48
|
+
/** Why the daemon is in 'error' state (start failed / file no longer loads). */
|
|
49
|
+
error?: string;
|
|
50
|
+
};
|
|
21
51
|
export type FunctionUploadRecord = {
|
|
22
52
|
timestamp: string;
|
|
23
53
|
/** 'upload' = POST /functions/upload, 'call' = multipart function call. */
|
|
@@ -51,6 +81,9 @@ export declare const createFunctionsStatusTracker: (maxHistory?: number) => {
|
|
|
51
81
|
recordCall(record: Omit<FunctionCallRecord, 'timestamp'>): void;
|
|
52
82
|
recordUpload(record: Omit<FunctionUploadRecord, 'timestamp'>): void;
|
|
53
83
|
recordDownload(record: Omit<FunctionDownloadRecord, 'timestamp'>): void;
|
|
84
|
+
recordDaemonActivity(record: Omit<DaemonActivityRecord, 'timestamp'>): void;
|
|
85
|
+
/** Register (or clear, with null) the daemon manager's live-state source. */
|
|
86
|
+
setDaemonsProvider(provider: (() => DaemonStateSnapshot[]) | null): void;
|
|
54
87
|
recordAuth(record: Omit<FunctionAuthRecord, 'timestamp'>): void;
|
|
55
88
|
snapshot(): {
|
|
56
89
|
counters: {
|
|
@@ -72,12 +105,18 @@ export declare const createFunctionsStatusTracker: (maxHistory?: number) => {
|
|
|
72
105
|
total: number;
|
|
73
106
|
denied: number;
|
|
74
107
|
};
|
|
108
|
+
daemons: {
|
|
109
|
+
invocations: number;
|
|
110
|
+
failed: number;
|
|
111
|
+
};
|
|
75
112
|
};
|
|
113
|
+
daemons: DaemonStateSnapshot[];
|
|
76
114
|
history: {
|
|
77
115
|
calls: FunctionCallRecord[];
|
|
78
116
|
uploads: FunctionUploadRecord[];
|
|
79
117
|
downloads: FunctionDownloadRecord[];
|
|
80
118
|
auth: FunctionAuthRecord[];
|
|
119
|
+
daemons: DaemonActivityRecord[];
|
|
81
120
|
};
|
|
82
121
|
};
|
|
83
122
|
};
|
|
@@ -87,6 +126,9 @@ export declare const functionsStatusTracker: {
|
|
|
87
126
|
recordCall(record: Omit<FunctionCallRecord, 'timestamp'>): void;
|
|
88
127
|
recordUpload(record: Omit<FunctionUploadRecord, 'timestamp'>): void;
|
|
89
128
|
recordDownload(record: Omit<FunctionDownloadRecord, 'timestamp'>): void;
|
|
129
|
+
recordDaemonActivity(record: Omit<DaemonActivityRecord, 'timestamp'>): void;
|
|
130
|
+
/** Register (or clear, with null) the daemon manager's live-state source. */
|
|
131
|
+
setDaemonsProvider(provider: (() => DaemonStateSnapshot[]) | null): void;
|
|
90
132
|
recordAuth(record: Omit<FunctionAuthRecord, 'timestamp'>): void;
|
|
91
133
|
snapshot(): {
|
|
92
134
|
counters: {
|
|
@@ -108,12 +150,18 @@ export declare const functionsStatusTracker: {
|
|
|
108
150
|
total: number;
|
|
109
151
|
denied: number;
|
|
110
152
|
};
|
|
153
|
+
daemons: {
|
|
154
|
+
invocations: number;
|
|
155
|
+
failed: number;
|
|
156
|
+
};
|
|
111
157
|
};
|
|
158
|
+
daemons: DaemonStateSnapshot[];
|
|
112
159
|
history: {
|
|
113
160
|
calls: FunctionCallRecord[];
|
|
114
161
|
uploads: FunctionUploadRecord[];
|
|
115
162
|
downloads: FunctionDownloadRecord[];
|
|
116
163
|
auth: FunctionAuthRecord[];
|
|
164
|
+
daemons: DaemonActivityRecord[];
|
|
117
165
|
};
|
|
118
166
|
};
|
|
119
167
|
};
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
export declare const TYPES_FILE_NAME = "buttress-functions.d.ts";
|
|
10
10
|
export declare const TSCONFIG_FILE_NAME = "tsconfig.json";
|
|
11
11
|
export declare const EXAMPLE_FILE_NAME = "_example.ts";
|
|
12
|
-
export declare const
|
|
12
|
+
export declare const EXAMPLE_DAEMON_FILE_NAME = "_example-daemon.ts";
|
|
13
|
+
export declare const TYPES_TEMPLATE = "// Generated by @fugood/buttress-server \u2014 do not edit.\n// Rewritten on every server start to match the running version.\n//\n// Local functions are EXPERIMENTAL: this contract may change between\n// releases \u2014 after a server upgrade, re-read this file for the current shape.\n\n/** Result of `context.spawn(...)`. */\ntype ButtressSpawnResult = {\n /** Exit code, or null when the process was killed by a signal. */\n code: number | null\n signal: NodeJS.Signals | null\n stdout: string | Buffer\n stderr: string | Buffer\n /** True when output hit `maxBuffer` and capture stopped early. */\n truncated: boolean\n}\n\ntype ButtressSpawnOptions = {\n cwd?: string\n /** Merged over the server's own environment. */\n env?: Record<string, string | undefined>\n /** Written to stdin, which is then closed. */\n input?: string | Uint8Array\n /** 'utf8' (default) yields strings; 'buffer' yields Buffers. */\n encoding?: 'utf8' | 'buffer'\n /** Per-stream capture cap in bytes (default 8MB). */\n maxBuffer?: number\n onStdout?: (chunk: Buffer) => void\n onStderr?: (chunk: Buffer) => void\n}\n\ntype ButtressCompletionResult = {\n content: string\n reasoning_content?: string\n tool_calls?: any[]\n interrupted?: boolean\n usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }\n}\n\ntype ButtressEmbeddingResult = {\n /** Plain numbers, ready for JSON or conversion into a sqlite-vec float32 BLOB. */\n embedding: number[]\n}\n\n/** 'InSubspace' scopes a property to the space; 'Global' is cross-application. */\ntype ButtressBankPropertyMeta = 'InSubspace' | 'Global'\n\ntype ButtressBankProperty = {\n propertyId: string\n meta: ButtressBankPropertyMeta\n definition: Record<string, any> | null\n value: any\n tags: string[] | null\n lastUpdateHash: string | null\n lastUpdateNote: string | null\n lastUpdateKey: string | null\n createAt: string | null\n updateAt: string | null\n}\n\n/**\n * The Bank replaces `value` unconditionally on update \u2014 an omitted `value`\n * clears the stored one. Read-merge first when a partial update is intended.\n */\ntype ButtressBankPropertyInput = {\n propertyId: string\n /** Defaults to 'InSubspace'. */\n meta?: ButtressBankPropertyMeta\n definition?: Record<string, any>\n newDefinitionFallback?: Record<string, any>\n value?: any\n updateNote?: string\n /** Bank defaults upsert to true; pass false to require an existing property. */\n upsert?: boolean\n tags?: string[]\n}\n\ntype ButtressFunctionContext = {\n /**\n * Run a child process. Resolves once it exits \u2014 a non-zero `code` is a\n * normal resolution, so check it yourself. Rejects only when the process\n * cannot be started. Every process a call spawns is killed when the call\n * ends or times out.\n */\n spawn: (\n command: string,\n args?: string[],\n options?: ButtressSpawnOptions,\n ) => Promise<ButtressSpawnResult>\n\n buttress: {\n /**\n * Run a chat completion on this server's LLM generator. `messages` are\n * rendered with the model's own chat template, and thinking is off unless\n * you pass `enable_thinking: true` (which fills `reasoning_content`).\n */\n completion: (options: {\n /** Configured `[[generators]]` model; defaults to the first one. */\n model?: string\n messages?: { role: string; content: any }[]\n /**\n * Raw prompt sent without the chat template \u2014 the fallback for models\n * whose template the server cannot apply. Use instead of `messages`.\n */\n prompt?: string\n /** Cap on generated tokens. Unbounded when omitted. */\n max_tokens?: number\n temperature?: number\n /** Emit the model's reasoning into `reasoning_content` (default false). */\n enable_thinking?: boolean\n /** Called for every streamed token event. */\n onToken?: (event: { token?: string; content?: string; [key: string]: any }) => void\n /** Any other backend sampling option (`top_p`, `stop`, `tools`, \u2026). */\n [param: string]: any\n }) => Promise<ButtressCompletionResult>\n\n /**\n * Embed text with a GGML generator whose `[generators.model]` table sets\n * `embedding = true`. The vector is returned as plain numbers.\n */\n embedding: (options: {\n model?: string\n text: string\n /** llama.cpp normalization mode; 2 (L2) is the native default. */\n embd_normalize?: number\n }) => Promise<ButtressEmbeddingResult>\n\n /** Tokenize text with a configured GGML or MLX generator. */\n tokenize: (options: {\n model?: string\n text: string\n params?: Record<string, any>\n }) => Promise<Record<string, any> & { tokens: number[] }>\n\n /** Turn model token ids back into text. */\n detokenize: (options: { model?: string; tokens: number[] }) => Promise<string>\n\n /** Transcribe audio with this server's STT generator. */\n transcribe: (options: {\n /** Must match a configured STT model (`repo_id` or `repo_id:filename`). */\n model?: string\n filePath?: string\n audioData?: Uint8Array | Buffer\n options?: Record<string, any>\n }) => Promise<any>\n\n /**\n * Synthesize speech with this server's TTS generator (onnx-tts or\n * ggml-tts). The WAV is written into `tempDir`; return\n * `fileUrl(path)` to let callers download it.\n */\n synthesize: (options: {\n /** Must match a configured TTS model when given. */\n model?: string\n text: string\n /** Backend options; `options.speaker` picks a registered voice. */\n options?: Record<string, any>\n }) => Promise<{ path: string; sampling_rate: number; channels: number }>\n }\n\n /**\n * Daemon functions running on this server (files with `meta.daemon`).\n */\n daemons: {\n /**\n * Deliver an event to the named daemon's `context.onEvent` handlers;\n * it arrives as `{ event, data, source }`. Throws when no daemon of\n * that name is running, or when it registered no handler.\n */\n emit: (name: string, event: string, data?: unknown) => void\n /** Names of the daemons currently running. */\n list: () => string[]\n }\n\n /**\n * The bound workspace's remote Data Bank (read/write; daemon functions can\n * also subscribe to changes via `bank.subscribe`). Every method throws\n * until credentials are stored via `bricks buttress bank-key` (run on\n * this host, then restart the server).\n */\n bank: {\n /** List properties, optionally filtered. */\n list: (options?: {\n /** Match against definition title/description or tags. */\n keyword?: string\n meta?: ButtressBankPropertyMeta\n /** Restrict to these property ids. */\n ids?: string[]\n }) => Promise<ButtressBankProperty[]>\n\n /** One property, or null when it does not exist. */\n get: (propertyId: string) => Promise<ButtressBankProperty | null>\n\n /**\n * Create or update properties (upsert by default). Updates notify\n * subscribed devices unless `dontNotify` is set.\n */\n update: (\n properties: ButtressBankPropertyInput[],\n options?: { dontNotify?: boolean },\n ) => Promise<ButtressBankProperty[]>\n\n /** Delete a property. Resolves false when it did not exist. */\n remove: (propertyId: string) => Promise<boolean>\n\n /**\n * Watch property ids for remote changes. Only available inside daemon\n * functions (throws elsewhere); closed automatically when the daemon\n * stops. The connection is shared and reconnects on its own \u2014 a network\n * drop never stops the daemon.\n */\n subscribe: (\n propertyIds: string[],\n onChange: (properties: ButtressBankProperty[]) => unknown,\n ) => { close: () => void }\n }\n\n fetch: typeof fetch\n /** Server-side log, prefixed with the function name. */\n log: (...args: unknown[]) => void\n /** Emit a progress event. Delivered only to SSE callers; a no-op otherwise. */\n emit: (event: string, data?: unknown) => void\n /** Aborted when the call times out or the caller disconnects. */\n signal: AbortSignal\n env: Record<string, string | undefined>\n /**\n * The `[functions.config]` table from the server config. Read once at\n * server start \u2014 unlike function files, config edits need a restart.\n */\n config: Record<string, any>\n /** Per-call scratch directory, created on first access. */\n tempDir: string\n /**\n * Download URL path (`/functions/files/...`) for a file inside `tempDir`;\n * relative input resolves against it. Callers fetch the URL with the same\n * auth as any function call. Scratch dirs are swept after ~24h.\n */\n fileUrl: (target: string) => string\n /** Absolute path of this functions directory. */\n dir: string\n /** Helper libraries: _, lodash, moment, math, mathjs, voca, chroma, json5, qs, bytes, ms, nanoid, md5. */\n libs: Record<string, any>\n}\n\ntype ButtressFunctionMeta = {\n /** Shown to MCP clients in `tools/list`. */\n description?: string\n /** JSON Schema for the input object, passed to MCP verbatim. */\n parameters?: Record<string, any>\n /**\n * Deadline for this function: ms, or a duration string like \"10m\".\n * Ignored for daemons \u2014 a daemon has no deadline.\n */\n timeout?: number | string\n /**\n * `true` declares this file as a daemon: a long-lived background\n * function. Its default export runs once, with a ButtressDaemonContext as\n * its only argument, and stays alive \u2014 everything registered there\n * (`setInterval` timers, `bank.subscribe` subscriptions, `onEvent`\n * handlers) keeps firing until the file changes or the server stops.\n * A daemon is not an MCP tool and cannot be called over HTTP.\n */\n daemon?: boolean\n}\n\n/** An event another local function sent via `context.daemons.emit`. */\ntype ButtressDaemonEmittedEvent = {\n event: string\n data?: unknown\n /** Name of the local function (or daemon) that emitted it. */\n source: string\n}\n\n/**\n * What a daemon's default export receives: the full function context plus\n * lifetime APIs. `context.signal` aborts when the daemon stops (file\n * changed/removed, or server shutdown); an optional cleanup function\n * *returned* by the handler also runs at that point.\n */\ntype ButtressDaemonContext = ButtressFunctionContext & {\n /**\n * A managed `setInterval`: the callback keeps firing until cleared or\n * the daemon stops. A callback that throws is logged (and shown on\n * /status) \u2014 it never stops the timer or the daemon.\n */\n setInterval: (callback: () => unknown, ms: number) => unknown\n clearInterval: (handle: unknown) => void\n /** Receive events other local functions send via `context.daemons.emit`. */\n onEvent: (handler: (event: ButtressDaemonEmittedEvent) => unknown) => void\n}\n\n// --- Custom auth ------------------------------------------------------------\n// Drop an `_auth.ts` into this directory to gate the /functions endpoints\n// with your own logic. It exports the same shape as a function file:\n//\n// export const meta: ButtressAuthMeta = { mode: 'both' }\n// export default async function (\n// request: ButtressAuthRequest,\n// context: ButtressAuthContext,\n// ): Promise<ButtressAuthResult> { ... }\n//\n// While an `_auth` file exists but fails to load, every call is rejected.\n\ntype ButtressAuthMeta = {\n /**\n * 'both' (default): runs after the built-in workspace auth passes, as an\n * extra gate. 'override': replaces workspace auth \u2014 this function is the\n * only authority (a presented workspace token is still verified into\n * `request.workspaceAuth` so you can choose to honor it).\n */\n mode?: 'override' | 'both'\n}\n\ntype ButtressAuthRequest = {\n method: string\n path: string\n /** Function name for `GET`/`POST /functions/<name>`; undefined for list and MCP. */\n name?: string\n headers: Record<string, string | undefined>\n query: Record<string, string | undefined>\n /** Raw bearer token (Authorization header or `?token=`), if any. */\n token: string | null\n workspaceAuth: {\n /** Whether this server is bound to a workspace. */\n bound: boolean\n /** Whether the caller presented a valid workspace access token. */\n authenticated: boolean\n identity: {\n workspaceId: string\n subjectType: 'ws' | 'dev'\n subjectId: string\n jti?: string\n exp: number\n } | null\n }\n}\n\ntype ButtressAuthContext = {\n /** Server-side log, prefixed with \"_auth\". */\n log: (...args: unknown[]) => void\n fetch: typeof fetch\n env: Record<string, string | undefined>\n /** The `[functions.config]` table, same as `context.config` in functions. */\n config: Record<string, any>\n /** Absolute path of this functions directory. */\n dir: string\n /** Same helper libraries functions get. */\n libs: Record<string, any>\n}\n\n/** Only `true` (or `{ ok: true }`) allows the request; anything else denies. */\ntype ButtressAuthResult =\n | boolean\n | {\n ok: boolean\n /** Response status for a denial, 400-499 (default 403). */\n status?: number\n /** Message returned to the caller on denial. */\n error?: string\n }\n\n// The only non-builtin packages the local-function loader accepts. These\n// declarations keep editor support working when this directory is outside the\n// server package's own node_modules resolution tree.\ndeclare module 'sqlite3' {\n type SqliteCallback = (error: Error | null) => void\n\n class Database {\n constructor(filename: string, callback?: SqliteCallback)\n run(sql: string, ...params: any[]): this\n all<T = Record<string, any>>(\n sql: string,\n ...params: [...any[], (error: Error | null, rows: T[]) => void]\n ): this\n exec(sql: string, callback?: SqliteCallback): this\n loadExtension(filename: string, callback?: SqliteCallback): this\n close(callback?: SqliteCallback): void\n }\n\n const sqlite3: { Database: typeof Database }\n export { Database }\n export default sqlite3\n}\n\ndeclare module 'sqlite-vec' {\n export function getLoadablePath(): string\n export function load(database: { loadExtension(path: string): unknown }): void\n}\n";
|
|
13
14
|
export declare const TSCONFIG_TEMPLATE = "{\n // Editor support for Buttress local functions.\n // Install @types/node here for typings on \"node:*\" imports.\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"Bundler\",\n \"lib\": [\"ES2023\"],\n \"strict\": true,\n \"noEmit\": true,\n \"allowJs\": true,\n \"skipLibCheck\": true\n },\n \"include\": [\"**/*.ts\", \"**/*.js\"]\n}\n";
|
|
14
15
|
export declare const EXAMPLE_TEMPLATE = "// Example Buttress local function.\n//\n// Rename (or copy) this file to expose it: files starting with \"_\" are\n// ignored. The tool name is the file name \u2014 \"video-duration.ts\" becomes the\n// tool \"video-duration\", callable over MCP, at POST /functions/video-duration\n// with a JSON body, and at GET /functions/video-duration?path=... with the\n// input in the query string.\n\nexport const meta: ButtressFunctionMeta = {\n description: 'Report the duration of a video file using ffprobe',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Absolute path to a video file' },\n },\n required: ['path'],\n },\n timeout: '2m',\n}\n\nexport default async function (\n { path }: { path: string },\n context: ButtressFunctionContext,\n): Promise<{ seconds: number }> {\n const { code, stdout, stderr } = await context.spawn('ffprobe', [\n '-v',\n 'error',\n '-show_entries',\n 'format=duration',\n '-of',\n 'default=noprint_wrappers=1:nokey=1',\n path,\n ])\n\n if (code !== 0) throw new Error(`ffprobe failed (${code}): ${stderr}`)\n\n return { seconds: Number(String(stdout).trim()) }\n}\n";
|
|
16
|
+
export declare const EXAMPLE_DAEMON_TEMPLATE = "// Example Buttress daemon function.\n//\n// Rename (or copy) this file to activate it: files starting with \"_\" are\n// ignored. A file with `meta.daemon = true` runs as a daemon \u2014 a\n// long-lived background function. It is not exposed as an MCP tool or\n// endpoint; its live status shows on /status.\n//\n// The default export runs ONCE when the daemon starts, and everything it\n// registers on the context keeps running after it returns:\n//\n// context.setInterval(cb, ms) periodic work (cleared on stop;\n// a throwing callback never stops it)\n// context.bank.subscribe(ids, cb) Data Bank change notifications\n// (needs `bricks buttress bank-key`)\n// context.onEvent(cb) events from other local functions:\n// context.daemons.emit(name, event, data)\n//\n// The daemon stops when this file changes or the server shuts down:\n// context.signal aborts, timers and subscriptions are cleaned up, and a\n// cleanup function returned here gets a final (bounded) run.\n\nexport const meta: ButtressFunctionMeta = {\n description: 'Log a heartbeat and react to Data Bank changes',\n daemon: true,\n}\n\nexport default async function (context: ButtressDaemonContext) {\n let ticks = 0\n\n context.setInterval(() => {\n ticks += 1\n context.log(`heartbeat #${ticks}`)\n }, 60_000)\n\n // Requires stored Bank credentials \u2014 uncomment after `bricks buttress bank-key`:\n // context.bank.subscribe(['some-property-id'], (properties) => {\n // for (const property of properties) {\n // context.log(`bank property changed: ${property.propertyId}`)\n // }\n // })\n\n context.onEvent(({ event, data, source }) => {\n context.log(`event \"${event}\" from ${source}`, data)\n })\n\n context.log('daemon started')\n return () => context.log(`stopping after ${ticks} tick(s)`)\n}\n";
|