@nettee/beacon 0.2.9 → 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 +167 -7
- 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/instructions.js +1 -1
- package/dist/run/orchestrator.js +21 -2
- package/dist/run/queue.js +32 -7
- package/dist/runtime/system-prompt.js +12 -0
- 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,7 +67,7 @@ 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 `reply_text` / `reply_card` /
|
|
70
|
+
inbound vs Schedule vs event vs manual capabilities, the `reply_text` / `reply_card` /
|
|
71
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:
|
|
@@ -120,6 +120,166 @@ the current time.
|
|
|
120
120
|
After sleep or restart, overdue occurrences are reconciled and coalesced to the
|
|
121
121
|
most recent one.
|
|
122
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
|
+
|
|
123
283
|
## Commands
|
|
124
284
|
|
|
125
285
|
```text
|
|
@@ -162,14 +322,14 @@ An Agent closes the conversational channel with `reply_text`, `reply_card`, or
|
|
|
162
322
|
`no_reply`, and may also call `notify_card` when the Run has a notify target:
|
|
163
323
|
|
|
164
324
|
- `reply_text` sends plain text. Inbound Feishu messages quote-reply the user.
|
|
165
|
-
Schedules message the Profile admin.
|
|
325
|
+
Schedules and event Runs message the Profile admin.
|
|
166
326
|
- `reply_card` sends an interactive card on the same conversational channel
|
|
167
|
-
(inbound quote-reply, or Schedule admin chat). It closes that channel by
|
|
327
|
+
(inbound quote-reply, or Schedule/event admin chat). It closes that channel by
|
|
168
328
|
itself.
|
|
169
329
|
- `no_reply` finishes without messaging the conversational channel. Its
|
|
170
330
|
`reason` is stored for audit and is never sent. Do not use it on inbound
|
|
171
331
|
messages.
|
|
172
|
-
- `notify_card` posts an interactive card to the Schedule's configured group.
|
|
332
|
+
- `notify_card` posts an interactive card to the Schedule or listener's configured group.
|
|
173
333
|
Only valid when the Run has a notify target.
|
|
174
334
|
|
|
175
335
|
Which of `reply_text` / `reply_card` / `notify_card` / `no_reply` to use for a
|
|
@@ -202,7 +362,7 @@ these argument shapes:
|
|
|
202
362
|
|
|
203
363
|
Beacon binds destinations; the Agent never supplies a `chat_id`. Text remains
|
|
204
364
|
text. Cards are sent with Feishu's `interactive` message type and include the
|
|
205
|
-
card generation time. A
|
|
365
|
+
card generation time. A Schedule or event `no_reply` without `notify_card` records a
|
|
206
366
|
successful Run with no Delivery. Failures `reply_text` a short error and never
|
|
207
367
|
notify a group. Manual local triggers print reply text/card and any notify card
|
|
208
368
|
as Markdown on stdout.
|
|
@@ -235,7 +395,7 @@ pi --export /ABSOLUTE/SESSION/PATH/TIMESTAMP_run_REPORTED_ID.jsonl run.html
|
|
|
235
395
|
`beacon doctor` keeps using `--no-session`, so its Pi smoke tests do not create
|
|
236
396
|
diagnostic session files.
|
|
237
397
|
|
|
238
|
-
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.
|
|
239
399
|
|
|
240
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.
|
|
241
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
|
+
}
|