@nettee/beacon 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -24
- package/dist/config/event-credentials.js +67 -0
- package/dist/config/global.js +23 -0
- package/dist/config/profile.js +26 -0
- package/dist/dashboard/records.js +10 -1
- package/dist/dashboard/server.js +3 -0
- package/dist/doctor.js +3 -0
- package/dist/events/cloudevent.js +93 -0
- package/dist/events/pipeline.js +99 -0
- package/dist/events/server.js +157 -0
- package/dist/events/store.js +183 -0
- package/dist/outcome/content.js +8 -5
- package/dist/outcome/instructions.js +5 -3
- package/dist/run/orchestrator.js +25 -5
- package/dist/run/queue.js +32 -7
- package/dist/runtime/pi-outcome-extension.js +61 -5
- package/dist/runtime/system-prompt.js +20 -7
- package/dist/service.js +69 -15
- package/dist/state/trigger-store.js +41 -37
- package/examples/config.yaml +8 -0
- package/examples/event-credentials.json.example +10 -0
- package/examples/profiles/example/profile.yaml +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Beacon
|
|
2
2
|
|
|
3
|
-
Beacon is a single-host service that turns Feishu messages
|
|
3
|
+
Beacon is a single-host service that turns Feishu messages, configured schedules, and subscribed CloudEvents into isolated Pi agent runs. Each accepted Trigger is durably claimed, produces an explicit Final Outcome, and may `reply_text` / `reply_card` to a person, `notify_card` a group, or stay silent on the admin channel.
|
|
4
4
|
|
|
5
5
|
This repository currently implements the Feishu + Pi MVP described by the
|
|
6
6
|
[Zest Dev Spec](https://github.com/nettee/beacon/blob/main/specs/change/20260913-beacon-feishu-pi-mvp/spec.md).
|
|
@@ -67,8 +67,8 @@ back to `prompt.md`. If neither place has a complete pair, Profile load fails
|
|
|
67
67
|
and lists the missing paths. Both pairs may exist; the workspace pair wins.
|
|
68
68
|
|
|
69
69
|
Beacon prepends an English platform template for this Run: workspace limit,
|
|
70
|
-
inbound vs Schedule vs manual capabilities, the `
|
|
71
|
-
`notify_card` contract, and an optional `submit_feedback` tool.
|
|
70
|
+
inbound vs Schedule vs event vs manual capabilities, the `reply_text` / `reply_card` /
|
|
71
|
+
`no_reply` / `notify_card` contract, and an optional `submit_feedback` tool.
|
|
72
72
|
|
|
73
73
|
Protect the home and secret file before running Beacon:
|
|
74
74
|
|
|
@@ -112,13 +112,174 @@ per-Run directories with mode `0700` when Pi starts.
|
|
|
112
112
|
Configuration is strict: unknown YAML/JSON fields, YAML aliases or warnings, missing paths, duplicate Schedule IDs, invalid timezones/cron expressions, a missing complete `persona.md` + `task.md` pair, escaped Profile markdown, permissive secret or runtime-environment permissions, and missing Profile credentials all fail startup. Beacon validates all Profiles before opening a Feishu connection.
|
|
113
113
|
|
|
114
114
|
Each Schedule uses a five-field cron expression and an IANA timezone. Profile
|
|
115
|
-
`admin.chat_id` is the private chat used for schedule `
|
|
116
|
-
for failure notices. Optional `notify.chat_id` on a
|
|
117
|
-
receives `notify_card`. Beacon deliberately does not
|
|
118
|
-
another destination. A newly discovered Schedule starts at
|
|
115
|
+
`admin.chat_id` is the private chat used for schedule `reply_text` /
|
|
116
|
+
`reply_card` / `no_reply` and for failure notices. Optional `notify.chat_id` on a
|
|
117
|
+
Schedule is the group that receives `notify_card`. Beacon deliberately does not
|
|
118
|
+
infer or fall back to another destination. A newly discovered Schedule starts at
|
|
119
|
+
the current time.
|
|
119
120
|
After sleep or restart, overdue occurrences are reconciled and coalesced to the
|
|
120
121
|
most recent one.
|
|
121
122
|
|
|
123
|
+
## Event triggers
|
|
124
|
+
|
|
125
|
+
Beacon accepts CloudEvents on a dedicated, opt-in HTTP server. A Profile's
|
|
126
|
+
optional `listener` selects the events it receives; Profiles without a listener
|
|
127
|
+
never receive external events. Direct Feishu/manual inputs and Schedules keep
|
|
128
|
+
using their existing paths:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
CloudEvent → authenticate → match + persist recipients ─────┐
|
|
132
|
+
Time → Schedule due ──────────────────────────────────┼→ Trigger → Run
|
|
133
|
+
Input → directly addressed Profile ────────────────────┘
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Enable the server in `config.yaml` (all values below except `enabled` and
|
|
137
|
+
`credentials_file` show defaults):
|
|
138
|
+
|
|
139
|
+
```yaml
|
|
140
|
+
events:
|
|
141
|
+
enabled: true
|
|
142
|
+
listen: 127.0.0.1
|
|
143
|
+
port: 46184
|
|
144
|
+
max_body_bytes: 262144
|
|
145
|
+
max_pending: 1000
|
|
146
|
+
credentials_file: event-credentials.json
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`credentials_file` resolves relative to the directory containing `config.yaml`;
|
|
150
|
+
absolute paths also work. Copy
|
|
151
|
+
[`examples/event-credentials.json.example`](examples/event-credentials.json.example)
|
|
152
|
+
to that location and replace its token with a unique, random Bearer token of at
|
|
153
|
+
least 32 characters (for example, generate one with `openssl rand -hex 32`).
|
|
154
|
+
Each producer has a stable ID and an explicit list of allowed event sources:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"version": 1,
|
|
159
|
+
"producers": [
|
|
160
|
+
{
|
|
161
|
+
"id": "deployment-system",
|
|
162
|
+
"token": "REPLACE_WITH_A_UNIQUE_RANDOM_TOKEN_AT_LEAST_32_CHARACTERS",
|
|
163
|
+
"sources": ["https://deploy.example.com/production"]
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The credentials file must be a regular, non-symlink file owned by the Beacon
|
|
170
|
+
user with mode `0600`. Its parent directory must belong to that user and grant
|
|
171
|
+
no group/world access. Producer IDs and tokens must be unique, and source lists
|
|
172
|
+
must be nonempty. Tokens use Bearer-compatible characters. Credentials are not
|
|
173
|
+
passed to Pi, included in event records, or loaded from `runtime.env`.
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
chmod 700 "$HOME/.beacon"
|
|
177
|
+
chmod 600 "$HOME/.beacon/event-credentials.json"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Subscribe in the desired `profile.yaml`:
|
|
181
|
+
|
|
182
|
+
```yaml
|
|
183
|
+
admin:
|
|
184
|
+
chat_id: REPLACE_WITH_ADMIN_DIRECT_CHAT_ID
|
|
185
|
+
listener:
|
|
186
|
+
sources:
|
|
187
|
+
- https://deploy.example.com/production
|
|
188
|
+
types:
|
|
189
|
+
- com.example.deployment.completed.v1
|
|
190
|
+
notify:
|
|
191
|
+
chat_id: REPLACE_WITH_GROUP_CHAT_ID
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Both lists are required and nonempty. Values match exactly: **OR within each
|
|
195
|
+
list, AND between `sources` and `types`**. There are no wildcards, regular
|
|
196
|
+
expressions, or filters inside `data`. A listener requires `admin.chat_id`;
|
|
197
|
+
`listener.notify` is optional. Matching Profiles each receive one Trigger with
|
|
198
|
+
the complete event as input. Their `persona.md` and `task.md` define what to do.
|
|
199
|
+
`reply_text` and `reply_card` go to the configured admin; `no_reply` is allowed;
|
|
200
|
+
`notify_card` is available only when the listener has a notify target. The event
|
|
201
|
+
cannot choose a Profile, command, or delivery destination. All event fields
|
|
202
|
+
remain untrusted external data, even after producer authentication.
|
|
203
|
+
|
|
204
|
+
Restart Beacon after changing configuration or credentials. This example sends
|
|
205
|
+
one CloudEvents 1.0 structured JSON event. Set the token in your **sender's**
|
|
206
|
+
environment to the value in its producer credentials; do not put it in Beacon's
|
|
207
|
+
`runtime.env`:
|
|
208
|
+
|
|
209
|
+
```sh
|
|
210
|
+
export BEACON_EVENT_TOKEN='YOUR_CONFIGURED_PRODUCER_TOKEN'
|
|
211
|
+
curl --fail-with-body --include \
|
|
212
|
+
-X POST http://127.0.0.1:46184/v1/events \
|
|
213
|
+
-H "Authorization: Bearer ${BEACON_EVENT_TOKEN}" \
|
|
214
|
+
-H 'Content-Type: application/cloudevents+json' \
|
|
215
|
+
--data-binary '{
|
|
216
|
+
"specversion": "1.0",
|
|
217
|
+
"id": "deployment-20260929-001",
|
|
218
|
+
"source": "https://deploy.example.com/production",
|
|
219
|
+
"type": "com.example.deployment.completed.v1",
|
|
220
|
+
"subject": "services/payment",
|
|
221
|
+
"time": "2026-09-29T09:00:00Z",
|
|
222
|
+
"datacontenttype": "application/json",
|
|
223
|
+
"data": {"service": "payment", "version": "v2.3.0"}
|
|
224
|
+
}'
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Use a new event `id` for each new fact. When retrying delivery of the same fact,
|
|
228
|
+
keep the same event body and ID. Acceptance returns a `receipt_id`, `status`,
|
|
229
|
+
`accepted_at`, `recipients`, and `duplicate`, with a `Location` header pointing
|
|
230
|
+
to the receipt. Query it using the same producer's credentials:
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
export BEACON_EVENT_RECEIPT_ID='evt_REPLACE_WITH_RECEIPT_ID_FROM_RESPONSE'
|
|
234
|
+
curl --fail-with-body \
|
|
235
|
+
-H "Authorization: Bearer ${BEACON_EVENT_TOKEN}" \
|
|
236
|
+
"http://127.0.0.1:46184/v1/events/${BEACON_EVENT_RECEIPT_ID}"
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
| HTTP result | Meaning |
|
|
240
|
+
| --- | --- |
|
|
241
|
+
| `202` | New event durably accepted, independently of Run execution |
|
|
242
|
+
| `200` on POST | Identical event from the same producer already accepted; no new Trigger |
|
|
243
|
+
| `400` / `415` | Invalid event or unsupported representation; fix the request |
|
|
244
|
+
| `401` / `403` | Invalid credentials / producer not authorized for this `source` |
|
|
245
|
+
| `409` | Same `source + id` already belongs to different event content or another producer |
|
|
246
|
+
| `413` | Request exceeds `max_body_bytes` (256 KiB by default) |
|
|
247
|
+
| `503` | Intake unavailable or pending inbox full; retry with backoff, honoring `Retry-After` |
|
|
248
|
+
| `500` | Required intake work failed; acceptance was not confirmed; retry the same event after recovery |
|
|
249
|
+
|
|
250
|
+
Receipt queries return `200` only to the originating producer while its source
|
|
251
|
+
remains authorized; missing or inaccessible receipts return `404`. Receipt
|
|
252
|
+
status is `unmatched` when no Profile matched, `pending` while recipients await
|
|
253
|
+
dispatch, or `dispatched` when dispatch has settled for all recipients. Each
|
|
254
|
+
recipient includes `profile_id` and, once dispatch settles, `trigger_id`.
|
|
255
|
+
**`dispatched` does not mean the Runs succeeded.** Inspect their Trigger/Run
|
|
256
|
+
records or Dashboard for execution and delivery outcomes.
|
|
257
|
+
|
|
258
|
+
This first version accepts a single structured JSON CloudEvent per POST.
|
|
259
|
+
`specversion`, `id`, `source`, and `type` are required. Sources may be valid
|
|
260
|
+
relative URI references or absolute URIs. Data is optional and must be JSON;
|
|
261
|
+
`datacontenttype`, when supplied, must be `application/json` or an
|
|
262
|
+
`application/*+json` type, with an optional UTF-8 charset. Extension names use
|
|
263
|
+
lowercase letters/digits and values are strings, booleans, or 32-bit integers.
|
|
264
|
+
There are no provider adapters, batch envelopes, binary-mode CloudEvents,
|
|
265
|
+
`data_base64`, or compressed requests.
|
|
266
|
+
|
|
267
|
+
The durable inbox lives at `<Beacon home>/state/events/`. At acceptance Beacon
|
|
268
|
+
snapshots matching Profiles and their admin/notify destinations. Restart resumes
|
|
269
|
+
pending dispatch without rematching historical events, and each recipient
|
|
270
|
+
Profile is claimed once. Changing a listener affects only new events. Keep
|
|
271
|
+
Profiles referenced by pending receipts configured until dispatch settles;
|
|
272
|
+
removing one causes a visible fatal dispatch failure. Dispatch is serial and
|
|
273
|
+
waits for the shared Run queue, while HTTP acceptance remains independent.
|
|
274
|
+
Active Runs interrupted by restart fail rather than execute twice; failed Runs
|
|
275
|
+
are not retried automatically or retriggered by duplicate event delivery. Event
|
|
276
|
+
records, like existing state records, have no automatic retention cleanup;
|
|
277
|
+
manually deleting them removes deduplication history.
|
|
278
|
+
|
|
279
|
+
For remote senders, put HTTPS authentication-preserving reverse proxying in front
|
|
280
|
+
of the event port. Keep its default loopback binding when the proxy is on the
|
|
281
|
+
same host. Expose only the event endpoint, not Beacon's unauthenticated Dashboard.
|
|
282
|
+
|
|
122
283
|
## Commands
|
|
123
284
|
|
|
124
285
|
```text
|
|
@@ -157,18 +318,26 @@ messages include the `run_id` whenever a Run record was created.
|
|
|
157
318
|
|
|
158
319
|
## Final Outcomes
|
|
159
320
|
|
|
160
|
-
An Agent closes the conversational channel with `
|
|
161
|
-
also call `notify_card`
|
|
162
|
-
|
|
163
|
-
- `
|
|
164
|
-
Schedules message the Profile admin.
|
|
165
|
-
|
|
166
|
-
-
|
|
167
|
-
|
|
168
|
-
- `
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
321
|
+
An Agent closes the conversational channel with `reply_text`, `reply_card`, or
|
|
322
|
+
`no_reply`, and may also call `notify_card` when the Run has a notify target:
|
|
323
|
+
|
|
324
|
+
- `reply_text` sends plain text. Inbound Feishu messages quote-reply the user.
|
|
325
|
+
Schedules and event Runs message the Profile admin.
|
|
326
|
+
- `reply_card` sends an interactive card on the same conversational channel
|
|
327
|
+
(inbound quote-reply, or Schedule/event admin chat). It closes that channel by
|
|
328
|
+
itself.
|
|
329
|
+
- `no_reply` finishes without messaging the conversational channel. Its
|
|
330
|
+
`reason` is stored for audit and is never sent. Do not use it on inbound
|
|
331
|
+
messages.
|
|
332
|
+
- `notify_card` posts an interactive card to the Schedule or listener's configured group.
|
|
333
|
+
Only valid when the Run has a notify target.
|
|
334
|
+
|
|
335
|
+
Which of `reply_text` / `reply_card` / `notify_card` / `no_reply` to use for a
|
|
336
|
+
business result is decided by the Profile persona/task (or the situation), not
|
|
337
|
+
hard-coded by Beacon beyond channel availability (`notify_card` needs a notify
|
|
338
|
+
target; inbound cannot `no_reply`).
|
|
339
|
+
|
|
340
|
+
The card tools accept a title, Feishu-compatible Markdown body, and up to five
|
|
172
341
|
HTTP(S) link buttons. The first button is styled as primary. The tools accept
|
|
173
342
|
these argument shapes:
|
|
174
343
|
|
|
@@ -193,10 +362,10 @@ these argument shapes:
|
|
|
193
362
|
|
|
194
363
|
Beacon binds destinations; the Agent never supplies a `chat_id`. Text remains
|
|
195
364
|
text. Cards are sent with Feishu's `interactive` message type and include the
|
|
196
|
-
card generation time. A
|
|
197
|
-
successful Run with no Delivery. Failures `
|
|
198
|
-
notify a group. Manual local triggers print reply text and any notify card
|
|
199
|
-
Markdown on stdout.
|
|
365
|
+
card generation time. A Schedule or event `no_reply` without `notify_card` records a
|
|
366
|
+
successful Run with no Delivery. Failures `reply_text` a short error and never
|
|
367
|
+
notify a group. Manual local triggers print reply text/card and any notify card
|
|
368
|
+
as Markdown on stdout.
|
|
200
369
|
|
|
201
370
|
## State and failure behavior
|
|
202
371
|
|
|
@@ -226,7 +395,7 @@ pi --export /ABSOLUTE/SESSION/PATH/TIMESTAMP_run_REPORTED_ID.jsonl run.html
|
|
|
226
395
|
`beacon doctor` keeps using `--no-session`, so its Pi smoke tests do not create
|
|
227
396
|
diagnostic session files.
|
|
228
397
|
|
|
229
|
-
Duplicate Feishu events and
|
|
398
|
+
Duplicate Feishu events, CloudEvents, and Schedule occurrences do not start a second Run. Active Runs interrupted by restart fail rather than rerun. Pending Delivery can resume, while a Delivery interrupted after its external call began fails without resending. Run and Delivery success are recorded independently. Beacon does not automatically retry either one.
|
|
230
399
|
|
|
231
400
|
Secrets and ephemeral Run Capability tokens are excluded from persisted records and from the Pi environment except for the one Run-scoped Outcome capability.
|
|
232
401
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { eventSourceSchema } from "../events/cloudevent.js";
|
|
6
|
+
const credentialsSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
version: z.literal(1),
|
|
9
|
+
producers: z
|
|
10
|
+
.array(z
|
|
11
|
+
.object({
|
|
12
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/),
|
|
13
|
+
token: z
|
|
14
|
+
.string()
|
|
15
|
+
.min(32)
|
|
16
|
+
.regex(/^[A-Za-z0-9._~+/-]+=*$/),
|
|
17
|
+
sources: z.array(eventSourceSchema).min(1),
|
|
18
|
+
})
|
|
19
|
+
.strict())
|
|
20
|
+
.min(1),
|
|
21
|
+
})
|
|
22
|
+
.strict();
|
|
23
|
+
export async function loadEventCredentials(path) {
|
|
24
|
+
const canonicalParent = await realpath(dirname(path));
|
|
25
|
+
const parent = await stat(canonicalParent);
|
|
26
|
+
if ((parent.mode & 0o077) !== 0 ||
|
|
27
|
+
(typeof process.getuid === "function" && parent.uid !== process.getuid())) {
|
|
28
|
+
throw new Error("Event credentials parent must be owned by the current user and grant no group/world access");
|
|
29
|
+
}
|
|
30
|
+
if ((await lstat(path)).isSymbolicLink()) {
|
|
31
|
+
throw new Error("Event credentials must not be a symbolic link");
|
|
32
|
+
}
|
|
33
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
34
|
+
try {
|
|
35
|
+
const metadata = await file.stat();
|
|
36
|
+
if (!metadata.isFile() ||
|
|
37
|
+
(metadata.mode & 0o777) !== 0o600 ||
|
|
38
|
+
(typeof process.getuid === "function" &&
|
|
39
|
+
metadata.uid !== process.getuid())) {
|
|
40
|
+
throw new Error("Event credentials must be a regular file owned by the current user with mode 0600");
|
|
41
|
+
}
|
|
42
|
+
// Deliberately omit parser causes: malformed JSON can quote credential bytes.
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(await file.readFile("utf8"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error("Cannot read valid JSON event credentials");
|
|
49
|
+
}
|
|
50
|
+
const result = credentialsSchema.safeParse(parsed);
|
|
51
|
+
if (!result.success)
|
|
52
|
+
throw new Error("Invalid event credentials configuration");
|
|
53
|
+
const ids = new Set();
|
|
54
|
+
const tokens = new Set();
|
|
55
|
+
for (const producer of result.data.producers) {
|
|
56
|
+
if (ids.has(producer.id) || tokens.has(producer.token)) {
|
|
57
|
+
throw new Error("Event producer IDs and tokens must be unique");
|
|
58
|
+
}
|
|
59
|
+
ids.add(producer.id);
|
|
60
|
+
tokens.add(producer.token);
|
|
61
|
+
}
|
|
62
|
+
return result.data.producers;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await file.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
package/dist/config/global.js
CHANGED
|
@@ -26,6 +26,18 @@ const globalDocumentSchema = z
|
|
|
26
26
|
scheduler: z
|
|
27
27
|
.object({ max_occurrences_per_reconciliation: positiveInteger })
|
|
28
28
|
.strict(),
|
|
29
|
+
events: z
|
|
30
|
+
.object({
|
|
31
|
+
enabled: z.boolean().default(false),
|
|
32
|
+
listen: z.string().min(1).default("127.0.0.1"),
|
|
33
|
+
port: z.number().int().min(1).max(65535).default(46184),
|
|
34
|
+
max_body_bytes: positiveInteger.default(262144),
|
|
35
|
+
max_pending: positiveInteger.default(1000),
|
|
36
|
+
credentials_file: z.string().min(1).optional(),
|
|
37
|
+
})
|
|
38
|
+
.strict()
|
|
39
|
+
.refine((value) => !value.enabled || value.credentials_file !== undefined, "events.credentials_file is required when events are enabled")
|
|
40
|
+
.optional(),
|
|
29
41
|
dashboard: z
|
|
30
42
|
.object({
|
|
31
43
|
enabled: z.boolean().optional(),
|
|
@@ -105,6 +117,17 @@ export async function loadGlobalConfig(path) {
|
|
|
105
117
|
scheduler: {
|
|
106
118
|
maxOccurrencesPerReconciliation: document.scheduler.max_occurrences_per_reconciliation,
|
|
107
119
|
},
|
|
120
|
+
...(document.events?.enabled
|
|
121
|
+
? {
|
|
122
|
+
events: {
|
|
123
|
+
listen: document.events.listen,
|
|
124
|
+
port: document.events.port,
|
|
125
|
+
maxBodyBytes: document.events.max_body_bytes,
|
|
126
|
+
maxPending: document.events.max_pending,
|
|
127
|
+
credentialsPath: resolveFromHome(document.events.credentials_file),
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
: {}),
|
|
108
131
|
dashboard: {
|
|
109
132
|
enabled: document.dashboard?.enabled ?? defaultDashboard.enabled,
|
|
110
133
|
listen: document.dashboard?.listen ?? defaultDashboard.listen,
|
package/dist/config/profile.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile, realpath, stat } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { eventSourceSchema } from "../events/cloudevent.js";
|
|
5
6
|
import { nextOccurrence } from "../schedule/cron.js";
|
|
6
7
|
import { parseStrictYaml } from "./yaml.js";
|
|
7
8
|
const profileIdPattern = /^[a-z0-9](?:[a-z0-9_-]{0,62})$/;
|
|
@@ -51,6 +52,17 @@ const profileDocumentSchema = z
|
|
|
51
52
|
.strict()
|
|
52
53
|
.optional(),
|
|
53
54
|
schedules: z.array(scheduleSchema).default([]),
|
|
55
|
+
listener: z
|
|
56
|
+
.object({
|
|
57
|
+
sources: z.array(eventSourceSchema).min(1),
|
|
58
|
+
types: z.array(z.string().min(1)).min(1),
|
|
59
|
+
notify: z
|
|
60
|
+
.object({ chat_id: z.string().trim().min(1) })
|
|
61
|
+
.strict()
|
|
62
|
+
.optional(),
|
|
63
|
+
})
|
|
64
|
+
.strict()
|
|
65
|
+
.optional(),
|
|
54
66
|
})
|
|
55
67
|
.strict();
|
|
56
68
|
function assertProfileId(profileId) {
|
|
@@ -167,6 +179,9 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
|
|
|
167
179
|
if (config.schedules.length > 0 && !config.admin) {
|
|
168
180
|
throw new Error(`Profile ${profileId} with schedules must declare admin.chat_id`);
|
|
169
181
|
}
|
|
182
|
+
if (config.listener && !config.admin) {
|
|
183
|
+
throw new Error(`Profile ${profileId} with listener must declare admin.chat_id`);
|
|
184
|
+
}
|
|
170
185
|
const canonicalProfileDirectory = await realpath(profileDirectory);
|
|
171
186
|
const workspace = isAbsolute(config.workspace)
|
|
172
187
|
? config.workspace
|
|
@@ -193,6 +208,17 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
|
|
|
193
208
|
runtime: config.runtime,
|
|
194
209
|
model: config.model,
|
|
195
210
|
...(config.admin ? { admin: { chatId: config.admin.chat_id } } : {}),
|
|
211
|
+
...(config.listener
|
|
212
|
+
? {
|
|
213
|
+
listener: {
|
|
214
|
+
sources: config.listener.sources,
|
|
215
|
+
types: config.listener.types,
|
|
216
|
+
...(config.listener.notify
|
|
217
|
+
? { notify: { chatId: config.listener.notify.chat_id } }
|
|
218
|
+
: {}),
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
: {}),
|
|
196
222
|
schedules: config.schedules.map((schedule) => ({
|
|
197
223
|
id: schedule.id,
|
|
198
224
|
cron: schedule.cron,
|
|
@@ -46,6 +46,12 @@ function summarize(value, hasSessionFile) {
|
|
|
46
46
|
acceptedAt,
|
|
47
47
|
kind: asString(input?.kind) ?? "unknown",
|
|
48
48
|
scheduleId: asString(input?.scheduleId),
|
|
49
|
+
...(input?.kind === "event"
|
|
50
|
+
? {
|
|
51
|
+
eventSource: asString(asRecord(input.event)?.source),
|
|
52
|
+
eventType: asString(asRecord(input.event)?.type),
|
|
53
|
+
}
|
|
54
|
+
: {}),
|
|
49
55
|
hasNotifyTarget: asRecord(record.notifyTarget) !== undefined,
|
|
50
56
|
runId: asString(run?.runId),
|
|
51
57
|
state: asString(run?.state),
|
|
@@ -81,7 +87,7 @@ export async function listRunSummaries(profilesDirectory) {
|
|
|
81
87
|
if (!entries)
|
|
82
88
|
continue;
|
|
83
89
|
for (const entry of entries) {
|
|
84
|
-
if (!entry.isDirectory())
|
|
90
|
+
if (!entry.isDirectory() || /^\.claim-[0-9a-f-]{36}$/.test(entry.name))
|
|
85
91
|
continue;
|
|
86
92
|
const summary = await loadSummary(join(root, entry.name, "record.json"));
|
|
87
93
|
if (summary)
|
|
@@ -97,6 +103,9 @@ export function kindLabel(row) {
|
|
|
97
103
|
if (row.kind === "schedule" && row.scheduleId) {
|
|
98
104
|
return `schedule:${row.scheduleId}`;
|
|
99
105
|
}
|
|
106
|
+
if (row.kind === "event" && row.eventType) {
|
|
107
|
+
return `event:${row.eventType}${row.eventSource ? ` (${row.eventSource})` : ""}`;
|
|
108
|
+
}
|
|
100
109
|
return row.kind;
|
|
101
110
|
}
|
|
102
111
|
export function toRunListItem(row) {
|
package/dist/dashboard/server.js
CHANGED
|
@@ -28,6 +28,9 @@ function triggerFromRunSummary(summary) {
|
|
|
28
28
|
notify: summary.hasNotifyTarget,
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
|
+
if (summary.kind === "event") {
|
|
32
|
+
return { kind: "event", notify: summary.hasNotifyTarget };
|
|
33
|
+
}
|
|
31
34
|
if (summary.kind === "feishu_message") {
|
|
32
35
|
return { kind: "feishu_message", notify: false };
|
|
33
36
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadEventCredentials } from "./config/event-credentials.js";
|
|
1
2
|
import { loadGlobalConfig } from "./config/global.js";
|
|
2
3
|
import { loadProfileRegistry } from "./config/registry.js";
|
|
3
4
|
import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
|
|
@@ -8,6 +9,8 @@ export async function runDoctor(configPath) {
|
|
|
8
9
|
const global = await loadGlobalConfig(configPath);
|
|
9
10
|
const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
|
|
10
11
|
const profiles = await loadProfileRegistry(global.profilesDirectory);
|
|
12
|
+
if (global.events)
|
|
13
|
+
await loadEventCredentials(global.events.credentialsPath);
|
|
11
14
|
for (const profile of profiles) {
|
|
12
15
|
const credentials = await loadFeishuCredentials(profile.id, global.secretsPath);
|
|
13
16
|
await createFeishuGateway(credentials).checkReady();
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// RFC 3986 URI references, including relative paths. Do not normalize: source
|
|
3
|
+
// identity and listener matching use the exact value provided by the producer.
|
|
4
|
+
const uriReference = /^(?:([A-Za-z][A-Za-z0-9+.-]*):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/;
|
|
5
|
+
const pathCharacters = /^(?:[A-Za-z0-9\-._~:@!$&'()*+,;=/]|%[0-9A-Fa-f]{2})*$/;
|
|
6
|
+
const queryCharacters = /^(?:[A-Za-z0-9\-._~:@!$&'()*+,;=/?]|%[0-9A-Fa-f]{2})*$/;
|
|
7
|
+
const authorityCharacters = /^(?:[A-Za-z0-9\-._~:@!$&'()*+,;=[\]]|%[0-9A-Fa-f]{2})*$/;
|
|
8
|
+
export const eventSourceSchema = z
|
|
9
|
+
.string()
|
|
10
|
+
.min(1)
|
|
11
|
+
.refine((value) => {
|
|
12
|
+
const parts = uriReference.exec(value);
|
|
13
|
+
if (!parts)
|
|
14
|
+
return false;
|
|
15
|
+
const [, scheme, authority, path = "", query = "", fragment = ""] = parts;
|
|
16
|
+
if (!pathCharacters.test(path) ||
|
|
17
|
+
!queryCharacters.test(query) ||
|
|
18
|
+
!queryCharacters.test(fragment))
|
|
19
|
+
return false;
|
|
20
|
+
if (!scheme &&
|
|
21
|
+
authority === undefined &&
|
|
22
|
+
!path.startsWith("/") &&
|
|
23
|
+
(path.split("/")[0] ?? "").includes(":"))
|
|
24
|
+
return false;
|
|
25
|
+
if (authority !== undefined) {
|
|
26
|
+
if (!authorityCharacters.test(authority))
|
|
27
|
+
return false;
|
|
28
|
+
// Use HTTP's authority parser for host/IP/port syntax only. The original
|
|
29
|
+
// scheme, path and identity remain untouched, including relative sources.
|
|
30
|
+
try {
|
|
31
|
+
new URL(`http://${authority || "cloudevents.invalid"}/`);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}, "Must be a valid nonempty URI reference");
|
|
39
|
+
const knownAttributes = new Set([
|
|
40
|
+
"specversion",
|
|
41
|
+
"id",
|
|
42
|
+
"source",
|
|
43
|
+
"type",
|
|
44
|
+
"subject",
|
|
45
|
+
"time",
|
|
46
|
+
"dataschema",
|
|
47
|
+
"datacontenttype",
|
|
48
|
+
"data",
|
|
49
|
+
]);
|
|
50
|
+
export const cloudEventSchema = z
|
|
51
|
+
.object({
|
|
52
|
+
specversion: z.literal("1.0"),
|
|
53
|
+
id: z.string().min(1),
|
|
54
|
+
source: eventSourceSchema,
|
|
55
|
+
type: z.string().min(1),
|
|
56
|
+
subject: z.string().min(1).optional(),
|
|
57
|
+
time: z.iso.datetime({ offset: true }).optional(),
|
|
58
|
+
dataschema: eventSourceSchema
|
|
59
|
+
.refine((value) => /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value), "dataschema must be an absolute URI")
|
|
60
|
+
.optional(),
|
|
61
|
+
datacontenttype: z
|
|
62
|
+
.string()
|
|
63
|
+
.refine((value) => /^application\/(?:json|[a-z0-9!#$&^_.+-]+\+json)(?:\s*;\s*charset\s*=\s*(?:utf-8|"utf-8"))?$/i.test(value), "Only JSON event data is supported")
|
|
64
|
+
.optional(),
|
|
65
|
+
data: z.json().optional(),
|
|
66
|
+
})
|
|
67
|
+
.catchall(z.union([
|
|
68
|
+
z.string(),
|
|
69
|
+
z.boolean(),
|
|
70
|
+
z.number().int().min(-2147483648).max(2147483647),
|
|
71
|
+
]))
|
|
72
|
+
.superRefine((event, context) => {
|
|
73
|
+
for (const key of Object.keys(event)) {
|
|
74
|
+
if (knownAttributes.has(key))
|
|
75
|
+
continue;
|
|
76
|
+
if (key === "data_base64" || !/^[a-z0-9]+$/.test(key)) {
|
|
77
|
+
context.addIssue({
|
|
78
|
+
code: "custom",
|
|
79
|
+
path: [key],
|
|
80
|
+
message: key === "data_base64"
|
|
81
|
+
? "data_base64 is unsupported; send JSON data"
|
|
82
|
+
: "Extension names must contain only lowercase letters and digits",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
export function parseCloudEvent(input) {
|
|
88
|
+
return cloudEventSchema.parse(input);
|
|
89
|
+
}
|
|
90
|
+
export function matchesListener(event, listener) {
|
|
91
|
+
return (listener.sources.includes(event.source) &&
|
|
92
|
+
listener.types.includes(event.type));
|
|
93
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { matchesListener } from "./cloudevent.js";
|
|
2
|
+
import { EventIntakeError, } from "./store.js";
|
|
3
|
+
export class EventPipeline {
|
|
4
|
+
options;
|
|
5
|
+
work;
|
|
6
|
+
requested = false;
|
|
7
|
+
stopped = false;
|
|
8
|
+
failure;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
12
|
+
async accept(event, producer) {
|
|
13
|
+
if (this.stopped || this.failure)
|
|
14
|
+
throw new EventIntakeError(503, "Event intake unavailable");
|
|
15
|
+
if (!producer.sources.includes(event.source))
|
|
16
|
+
throw new EventIntakeError(403, "Event source is not authorized");
|
|
17
|
+
const recipients = this.options.contexts
|
|
18
|
+
.filter(({ profile }) => profile.listener && matchesListener(event, profile.listener))
|
|
19
|
+
.map(({ profile }) => {
|
|
20
|
+
if (!profile.admin)
|
|
21
|
+
throw new Error(`Listener Profile ${profile.id} has no admin destination`);
|
|
22
|
+
return {
|
|
23
|
+
profileId: profile.id,
|
|
24
|
+
chatId: profile.admin.chatId,
|
|
25
|
+
...(profile.listener?.notify
|
|
26
|
+
? { notifyChatId: profile.listener.notify.chatId }
|
|
27
|
+
: {}),
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
const result = await this.options.store.accept(event, producer.id, recipients, this.options.maxPending);
|
|
31
|
+
this.wake();
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
/** Call after Profile orchestrator recovery and after the HTTP listener binds. */
|
|
35
|
+
wake() {
|
|
36
|
+
if (this.stopped || this.failure)
|
|
37
|
+
return;
|
|
38
|
+
this.requested = true;
|
|
39
|
+
if (this.work)
|
|
40
|
+
return;
|
|
41
|
+
this.work = this.pump()
|
|
42
|
+
.catch((error) => {
|
|
43
|
+
this.failure = new Error(`Event dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error });
|
|
44
|
+
this.options.onFatal(this.failure);
|
|
45
|
+
})
|
|
46
|
+
.finally(() => {
|
|
47
|
+
this.work = undefined;
|
|
48
|
+
if (this.requested && !this.stopped && !this.failure)
|
|
49
|
+
this.wake();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async pump() {
|
|
53
|
+
while (this.requested && !this.stopped) {
|
|
54
|
+
this.requested = false;
|
|
55
|
+
for (const record of await this.options.store.pending()) {
|
|
56
|
+
for (const recipient of record.recipients) {
|
|
57
|
+
if (this.stopped)
|
|
58
|
+
return;
|
|
59
|
+
if (recipient.triggerId)
|
|
60
|
+
continue;
|
|
61
|
+
const context = this.options.contexts.find(({ profile }) => profile.id === recipient.profileId);
|
|
62
|
+
if (!context)
|
|
63
|
+
throw new Error(`Pending Event ${record.receiptId} requires missing Profile ${recipient.profileId}`);
|
|
64
|
+
const input = { kind: "event", event: record.event };
|
|
65
|
+
const claimed = await context.store.claim({
|
|
66
|
+
sourceKey: ["event", record.event.source, record.event.id],
|
|
67
|
+
acceptedAt: new Date(record.acceptedAt),
|
|
68
|
+
target: { kind: "chat", chatId: recipient.chatId },
|
|
69
|
+
...(recipient.notifyChatId
|
|
70
|
+
? {
|
|
71
|
+
notifyTarget: {
|
|
72
|
+
kind: "chat",
|
|
73
|
+
chatId: recipient.notifyChatId,
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
: {}),
|
|
77
|
+
input,
|
|
78
|
+
});
|
|
79
|
+
// Existing claims were recovered by the orchestrator before intake starts.
|
|
80
|
+
if (claimed.created)
|
|
81
|
+
await context.orchestrator.process(claimed.record.triggerKey, async () => input);
|
|
82
|
+
await this.options.store.markDispatched(record.receiptId, recipient.profileId, claimed.record.triggerId);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async close() {
|
|
88
|
+
this.stopped = true;
|
|
89
|
+
await this.work;
|
|
90
|
+
if (this.failure)
|
|
91
|
+
throw this.failure;
|
|
92
|
+
}
|
|
93
|
+
async drain() {
|
|
94
|
+
while (this.work)
|
|
95
|
+
await this.work;
|
|
96
|
+
if (this.failure)
|
|
97
|
+
throw this.failure;
|
|
98
|
+
}
|
|
99
|
+
}
|