@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/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((_resolve, reject) => {
59
- rejectFatal = reject;
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.race([shutdown.promise, fatal, ...gateways]);
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.sessionId !== record.run.runId ||
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 identity must map to its Profile ID and Run ID");
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 readConcurrentClaim(triggerKey) {
206
- for (let attempt = 0; attempt < 100; attempt += 1) {
207
- try {
208
- return await this.read(triggerKey);
209
- }
210
- catch (error) {
211
- const cause = error instanceof Error
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
- const directoryHandle = await open(directory, "r");
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
- await this.write(triggerKey, record);
274
- return { created: true, record };
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
  }
@@ -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
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "producers": [
4
+ {
5
+ "id": "deployment-system",
6
+ "token": "REPLACE_WITH_A_UNIQUE_RANDOM_TOKEN_AT_LEAST_32_CHARACTERS",
7
+ "sources": ["https://deploy.example.com/production"]
8
+ }
9
+ ]
10
+ }
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {