@nettee/beacon 0.2.9 → 0.3.1
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 +188 -17
- 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/feishu/gateway.js +19 -6
- package/dist/outcome/instructions.js +1 -1
- package/dist/run/orchestrator.js +55 -7
- 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 +64 -40
- 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/dist/service.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadEventCredentials } from "./config/event-credentials.js";
|
|
1
3
|
import { loadGlobalConfig } from "./config/global.js";
|
|
2
4
|
import { loadProfileRegistry } from "./config/registry.js";
|
|
3
5
|
import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
|
|
4
6
|
import { loadFeishuCredentials } from "./config/secrets.js";
|
|
5
7
|
import { startDashboard } from "./dashboard/server.js";
|
|
8
|
+
import { EventPipeline } from "./events/pipeline.js";
|
|
9
|
+
import { startEventServer } from "./events/server.js";
|
|
10
|
+
import { EventStore } from "./events/store.js";
|
|
6
11
|
import { createFeishuGateway } from "./feishu/gateway.js";
|
|
7
12
|
import { createFeishuMessagePipeline } from "./feishu/message-pipeline.js";
|
|
8
13
|
import { startOutcomeServer } from "./outcome/server.js";
|
|
@@ -34,6 +39,9 @@ export async function runBeacon(configPath) {
|
|
|
34
39
|
const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
|
|
35
40
|
const profiles = await loadProfileRegistry(global.profilesDirectory);
|
|
36
41
|
const credentials = await Promise.all(profiles.map((profile) => loadFeishuCredentials(profile.id, global.secretsPath)));
|
|
42
|
+
const eventProducers = global.events
|
|
43
|
+
? await loadEventCredentials(global.events.credentialsPath)
|
|
44
|
+
: [];
|
|
37
45
|
const outcomes = await startOutcomeServer();
|
|
38
46
|
const queue = new RunQueue(global.runs.maxConcurrent, global.runs.maxQueued);
|
|
39
47
|
let dashboard;
|
|
@@ -54,10 +62,16 @@ export async function runBeacon(configPath) {
|
|
|
54
62
|
console.error(`[beacon] dashboard failed to listen http://${global.dashboard.listen}:${String(global.dashboard.port)}: ${message}; continuing without dashboard`);
|
|
55
63
|
}
|
|
56
64
|
const shutdown = shutdownController();
|
|
65
|
+
let firstFatal;
|
|
57
66
|
let rejectFatal;
|
|
58
|
-
const fatal = new Promise((
|
|
59
|
-
rejectFatal =
|
|
67
|
+
const fatal = new Promise((resolve) => {
|
|
68
|
+
rejectFatal = (error) => {
|
|
69
|
+
firstFatal ??= error;
|
|
70
|
+
resolve(error);
|
|
71
|
+
};
|
|
60
72
|
});
|
|
73
|
+
let eventServer;
|
|
74
|
+
let eventPipeline;
|
|
61
75
|
const messagePipelines = [];
|
|
62
76
|
const loops = [];
|
|
63
77
|
const contexts = profiles.map((profile, index) => {
|
|
@@ -89,33 +103,73 @@ export async function runBeacon(configPath) {
|
|
|
89
103
|
});
|
|
90
104
|
const loop = new ScheduleLoop(profile, reconciler, rejectFatal);
|
|
91
105
|
loops.push(loop);
|
|
92
|
-
return { profile, messagePipeline, orchestrator, reconciler };
|
|
93
|
-
});
|
|
94
|
-
await Promise.all(contexts.map((context) => context.orchestrator.recover()));
|
|
95
|
-
const reconciliationTime = new Date();
|
|
96
|
-
await Promise.all(contexts.map((context) => context.reconciler.reconcile(reconciliationTime)));
|
|
97
|
-
for (const loop of loops)
|
|
98
|
-
loop.start(reconciliationTime);
|
|
99
|
-
const gateways = contexts.map(({ profile, messagePipeline }) => {
|
|
100
|
-
console.log(`[beacon] starting Profile id=${profile.id} runtime=${profile.runtime} provider=${profile.model.provider} model=${profile.model.id}`);
|
|
101
|
-
return messagePipeline.run(shutdown.promise);
|
|
106
|
+
return { profile, store, messagePipeline, orchestrator, reconciler };
|
|
102
107
|
});
|
|
108
|
+
let gateways = [];
|
|
109
|
+
const failures = [];
|
|
103
110
|
try {
|
|
104
|
-
await Promise.
|
|
111
|
+
await Promise.all(contexts.map((context) => context.orchestrator.recover()));
|
|
112
|
+
const reconciliationTime = new Date();
|
|
113
|
+
await Promise.all(contexts.map((context) => context.reconciler.reconcile(reconciliationTime)));
|
|
114
|
+
if (global.events) {
|
|
115
|
+
const store = new EventStore(join(global.homeDirectory, "state", "events"));
|
|
116
|
+
eventPipeline = new EventPipeline({
|
|
117
|
+
store,
|
|
118
|
+
contexts,
|
|
119
|
+
maxPending: global.events.maxPending,
|
|
120
|
+
onFatal: rejectFatal,
|
|
121
|
+
});
|
|
122
|
+
eventServer = await startEventServer({
|
|
123
|
+
...global.events,
|
|
124
|
+
producers: eventProducers,
|
|
125
|
+
pipeline: eventPipeline,
|
|
126
|
+
store,
|
|
127
|
+
onFatal: rejectFatal,
|
|
128
|
+
});
|
|
129
|
+
eventPipeline.wake();
|
|
130
|
+
console.log(`[beacon] events listening http://${global.events.listen}:${eventServer.port} (authenticated)`);
|
|
131
|
+
}
|
|
132
|
+
for (const loop of loops)
|
|
133
|
+
loop.start(reconciliationTime);
|
|
134
|
+
gateways = contexts.map(({ profile, messagePipeline }) => {
|
|
135
|
+
console.log(`[beacon] starting Profile id=${profile.id} runtime=${profile.runtime} provider=${profile.model.provider} model=${profile.model.id}`);
|
|
136
|
+
return messagePipeline.run(shutdown.promise);
|
|
137
|
+
});
|
|
138
|
+
const result = await Promise.race([shutdown.promise, fatal, ...gateways]);
|
|
139
|
+
if (result instanceof Error)
|
|
140
|
+
throw result;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
failures.push(error);
|
|
105
144
|
}
|
|
106
145
|
finally {
|
|
107
146
|
shutdown.resolve();
|
|
108
147
|
for (const loop of loops)
|
|
109
148
|
loop.stop();
|
|
149
|
+
// Stop HTTP intake, finish current event execution, leave the rest durable.
|
|
150
|
+
const stoppedEvents = await Promise.allSettled([
|
|
151
|
+
eventServer?.close() ?? Promise.resolve(),
|
|
152
|
+
eventPipeline?.close() ?? Promise.resolve(),
|
|
153
|
+
]);
|
|
110
154
|
await Promise.allSettled(gateways);
|
|
111
|
-
await Promise.allSettled([
|
|
155
|
+
const drained = await Promise.allSettled([
|
|
112
156
|
...messagePipelines.map((pipeline) => pipeline.drain()),
|
|
113
157
|
...loops.map((loop) => loop.drain()),
|
|
114
158
|
]);
|
|
115
159
|
shutdown.close();
|
|
116
|
-
await Promise.allSettled([
|
|
160
|
+
const closed = await Promise.allSettled([
|
|
117
161
|
dashboard?.close() ?? Promise.resolve(),
|
|
118
162
|
outcomes.close(),
|
|
119
163
|
]);
|
|
164
|
+
for (const result of [...stoppedEvents, ...drained, ...closed]) {
|
|
165
|
+
if (result.status === "rejected" && !failures.includes(result.reason))
|
|
166
|
+
failures.push(result.reason);
|
|
167
|
+
}
|
|
120
168
|
}
|
|
169
|
+
if (firstFatal && !failures.includes(firstFatal))
|
|
170
|
+
failures.push(firstFatal);
|
|
171
|
+
if (failures.length === 1)
|
|
172
|
+
throw failures[0];
|
|
173
|
+
if (failures.length > 1)
|
|
174
|
+
throw new AggregateError(failures, "Beacon service failed");
|
|
121
175
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { chmod, mkdir, open, readdir, readFile, rename, } from "node:fs/promises";
|
|
2
|
+
import { chmod, mkdir, open, readdir, readFile, rename, rm, } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { failureCodes, } from "../domain/types.js";
|
|
6
|
+
import { cloudEventSchema } from "../events/cloudevent.js";
|
|
6
7
|
import { feedbackRecordSchema, finalOutcomeContentSchema, parseFinalOutcomeContent, } from "../outcome/content.js";
|
|
7
8
|
const timestamp = z.string().datetime({ offset: true });
|
|
8
9
|
const failureSchema = z
|
|
@@ -23,6 +24,7 @@ const messageSchema = z
|
|
|
23
24
|
})
|
|
24
25
|
.strict();
|
|
25
26
|
const inputSchema = z.discriminatedUnion("kind", [
|
|
27
|
+
z.object({ kind: z.literal("event"), event: cloudEventSchema }).strict(),
|
|
26
28
|
z
|
|
27
29
|
.object({
|
|
28
30
|
kind: z.literal("feishu_message"),
|
|
@@ -137,10 +139,9 @@ function validateRecord(value) {
|
|
|
137
139
|
throw new Error("Run record must contain both Pi sessionId and sessionPath or neither");
|
|
138
140
|
}
|
|
139
141
|
if (record.run?.sessionId &&
|
|
140
|
-
(record.run.
|
|
141
|
-
basename(record.run.sessionPath) !== record.run.runId ||
|
|
142
|
+
(basename(record.run.sessionPath) !== record.run.sessionId ||
|
|
142
143
|
basename(dirname(record.run.sessionPath)) !== record.profileId)) {
|
|
143
|
-
throw new Error("Run Pi session
|
|
144
|
+
throw new Error("Run Pi session path must map to its Profile ID and session ID");
|
|
144
145
|
}
|
|
145
146
|
if (record.delivery && !record.finalOutcome) {
|
|
146
147
|
throw new Error("Trigger record with Delivery must contain a Final Outcome");
|
|
@@ -202,21 +203,14 @@ export class TriggerStore {
|
|
|
202
203
|
});
|
|
203
204
|
}
|
|
204
205
|
}
|
|
205
|
-
async
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
? error.cause
|
|
213
|
-
: undefined;
|
|
214
|
-
if (cause?.code !== "ENOENT")
|
|
215
|
-
throw error;
|
|
216
|
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
217
|
-
}
|
|
206
|
+
async syncDirectory(directory) {
|
|
207
|
+
const handle = await open(directory, "r");
|
|
208
|
+
try {
|
|
209
|
+
await handle.sync();
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await handle.close();
|
|
218
213
|
}
|
|
219
|
-
return this.read(triggerKey);
|
|
220
214
|
}
|
|
221
215
|
async write(triggerKey, record) {
|
|
222
216
|
const validated = validateRecord(record);
|
|
@@ -231,13 +225,7 @@ export class TriggerStore {
|
|
|
231
225
|
await handle.close();
|
|
232
226
|
}
|
|
233
227
|
await rename(temporary, this.recordPath(triggerKey));
|
|
234
|
-
|
|
235
|
-
try {
|
|
236
|
-
await directoryHandle.sync();
|
|
237
|
-
}
|
|
238
|
-
finally {
|
|
239
|
-
await directoryHandle.close();
|
|
240
|
-
}
|
|
228
|
+
await this.syncDirectory(directory);
|
|
241
229
|
}
|
|
242
230
|
async claim(request) {
|
|
243
231
|
if (request.sourceKey.length < 2 ||
|
|
@@ -247,18 +235,6 @@ export class TriggerStore {
|
|
|
247
235
|
await this.prepare();
|
|
248
236
|
const triggerKey = keyFor(this.profileId, request.sourceKey);
|
|
249
237
|
const directory = join(this.root, triggerKey);
|
|
250
|
-
try {
|
|
251
|
-
await mkdir(directory, { mode: 0o700 });
|
|
252
|
-
}
|
|
253
|
-
catch (error) {
|
|
254
|
-
if (error.code === "EEXIST") {
|
|
255
|
-
return {
|
|
256
|
-
created: false,
|
|
257
|
-
record: await this.readConcurrentClaim(triggerKey),
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
throw new Error(`Cannot claim Trigger ${triggerKey}`, { cause: error });
|
|
261
|
-
}
|
|
262
238
|
const record = {
|
|
263
239
|
version: 1,
|
|
264
240
|
triggerKey,
|
|
@@ -268,10 +244,37 @@ export class TriggerStore {
|
|
|
268
244
|
acceptedAt: (request.acceptedAt ?? new Date()).toISOString(),
|
|
269
245
|
target: request.target,
|
|
270
246
|
...(request.notifyTarget ? { notifyTarget: request.notifyTarget } : {}),
|
|
247
|
+
...(request.input ? { input: request.input } : {}),
|
|
271
248
|
...(request.ingress ? { ingress: request.ingress } : {}),
|
|
272
249
|
};
|
|
273
|
-
|
|
274
|
-
|
|
250
|
+
const temporaryKey = `.claim-${randomUUID()}`;
|
|
251
|
+
const temporaryDirectory = join(this.root, temporaryKey);
|
|
252
|
+
await mkdir(temporaryDirectory, { mode: 0o700 });
|
|
253
|
+
try {
|
|
254
|
+
// Publish only after the complete record and its directory are durable.
|
|
255
|
+
// POSIX rename may replace a legacy empty claim directory, but cannot
|
|
256
|
+
// replace a populated winner from another concurrent claimant.
|
|
257
|
+
await this.write(temporaryKey, record);
|
|
258
|
+
try {
|
|
259
|
+
await rename(temporaryDirectory, directory);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
const code = error.code;
|
|
263
|
+
if (code !== "EEXIST" && code !== "ENOTEMPTY") {
|
|
264
|
+
throw new Error(`Cannot publish Trigger ${triggerKey}`, {
|
|
265
|
+
cause: error,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
const existing = await this.read(triggerKey);
|
|
269
|
+
await this.syncDirectory(this.root);
|
|
270
|
+
return { created: false, record: existing };
|
|
271
|
+
}
|
|
272
|
+
await this.syncDirectory(this.root);
|
|
273
|
+
return { created: true, record };
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
277
|
+
}
|
|
275
278
|
}
|
|
276
279
|
async update(triggerKey, transform) {
|
|
277
280
|
const current = await this.read(triggerKey);
|
|
@@ -286,9 +289,30 @@ export class TriggerStore {
|
|
|
286
289
|
await this.prepare();
|
|
287
290
|
const entries = await readdir(this.root, { withFileTypes: true });
|
|
288
291
|
const records = await Promise.all(entries
|
|
289
|
-
.filter((entry) => entry.isDirectory())
|
|
292
|
+
.filter((entry) => entry.isDirectory() && !/^\.claim-[0-9a-f-]{36}$/.test(entry.name))
|
|
290
293
|
.map((entry) => this.read(entry.name)));
|
|
291
294
|
return records.sort((left, right) => left.acceptedAt.localeCompare(right.acceptedAt) ||
|
|
292
295
|
left.triggerId.localeCompare(right.triggerId));
|
|
293
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Locate the Trigger whose reply or notify Delivery produced this Feishu
|
|
299
|
+
* outbound `message_id`. Newest accepted Trigger wins on collision.
|
|
300
|
+
*/
|
|
301
|
+
async findByDeliveredMessageId(messageId) {
|
|
302
|
+
const trimmed = messageId.trim();
|
|
303
|
+
if (!trimmed)
|
|
304
|
+
return undefined;
|
|
305
|
+
const records = await this.list();
|
|
306
|
+
for (let index = records.length - 1; index >= 0; index -= 1) {
|
|
307
|
+
const record = records[index];
|
|
308
|
+
for (const field of ["delivery", "notifyDelivery"]) {
|
|
309
|
+
const delivery = record[field];
|
|
310
|
+
if (delivery?.state === "delivered" &&
|
|
311
|
+
delivery.providerRequestId === trimmed) {
|
|
312
|
+
return record;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
294
318
|
}
|
package/examples/config.yaml
CHANGED
|
@@ -15,3 +15,11 @@ dashboard:
|
|
|
15
15
|
enabled: true
|
|
16
16
|
listen: 0.0.0.0
|
|
17
17
|
port: 46183
|
|
18
|
+
# Opt in after creating a private event-credentials.json; see README.
|
|
19
|
+
events:
|
|
20
|
+
enabled: false
|
|
21
|
+
listen: 127.0.0.1
|
|
22
|
+
port: 46184
|
|
23
|
+
max_body_bytes: 262144
|
|
24
|
+
max_pending: 1000
|
|
25
|
+
credentials_file: event-credentials.json
|
|
@@ -12,3 +12,11 @@ schedules:
|
|
|
12
12
|
input: Prepare the weekday brief.
|
|
13
13
|
notify:
|
|
14
14
|
chat_id: REPLACE_WITH_GROUP_CHAT_ID
|
|
15
|
+
# Optional external event subscription. Both lists use exact matches.
|
|
16
|
+
# listener:
|
|
17
|
+
# sources:
|
|
18
|
+
# - https://deploy.example.com/production
|
|
19
|
+
# types:
|
|
20
|
+
# - com.example.deployment.completed.v1
|
|
21
|
+
# notify:
|
|
22
|
+
# chat_id: REPLACE_WITH_GROUP_CHAT_ID
|