@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.
@@ -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"),
@@ -202,21 +204,14 @@ export class TriggerStore {
202
204
  });
203
205
  }
204
206
  }
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
- }
207
+ async syncDirectory(directory) {
208
+ const handle = await open(directory, "r");
209
+ try {
210
+ await handle.sync();
211
+ }
212
+ finally {
213
+ await handle.close();
218
214
  }
219
- return this.read(triggerKey);
220
215
  }
221
216
  async write(triggerKey, record) {
222
217
  const validated = validateRecord(record);
@@ -231,13 +226,7 @@ export class TriggerStore {
231
226
  await handle.close();
232
227
  }
233
228
  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
- }
229
+ await this.syncDirectory(directory);
241
230
  }
242
231
  async claim(request) {
243
232
  if (request.sourceKey.length < 2 ||
@@ -247,18 +236,6 @@ export class TriggerStore {
247
236
  await this.prepare();
248
237
  const triggerKey = keyFor(this.profileId, request.sourceKey);
249
238
  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
239
  const record = {
263
240
  version: 1,
264
241
  triggerKey,
@@ -268,10 +245,37 @@ export class TriggerStore {
268
245
  acceptedAt: (request.acceptedAt ?? new Date()).toISOString(),
269
246
  target: request.target,
270
247
  ...(request.notifyTarget ? { notifyTarget: request.notifyTarget } : {}),
248
+ ...(request.input ? { input: request.input } : {}),
271
249
  ...(request.ingress ? { ingress: request.ingress } : {}),
272
250
  };
273
- await this.write(triggerKey, record);
274
- return { created: true, record };
251
+ const temporaryKey = `.claim-${randomUUID()}`;
252
+ const temporaryDirectory = join(this.root, temporaryKey);
253
+ await mkdir(temporaryDirectory, { mode: 0o700 });
254
+ try {
255
+ // Publish only after the complete record and its directory are durable.
256
+ // POSIX rename may replace a legacy empty claim directory, but cannot
257
+ // replace a populated winner from another concurrent claimant.
258
+ await this.write(temporaryKey, record);
259
+ try {
260
+ await rename(temporaryDirectory, directory);
261
+ }
262
+ catch (error) {
263
+ const code = error.code;
264
+ if (code !== "EEXIST" && code !== "ENOTEMPTY") {
265
+ throw new Error(`Cannot publish Trigger ${triggerKey}`, {
266
+ cause: error,
267
+ });
268
+ }
269
+ const existing = await this.read(triggerKey);
270
+ await this.syncDirectory(this.root);
271
+ return { created: false, record: existing };
272
+ }
273
+ await this.syncDirectory(this.root);
274
+ return { created: true, record };
275
+ }
276
+ finally {
277
+ await rm(temporaryDirectory, { recursive: true, force: true });
278
+ }
275
279
  }
276
280
  async update(triggerKey, transform) {
277
281
  const current = await this.read(triggerKey);
@@ -286,7 +290,7 @@ export class TriggerStore {
286
290
  await this.prepare();
287
291
  const entries = await readdir(this.root, { withFileTypes: true });
288
292
  const records = await Promise.all(entries
289
- .filter((entry) => entry.isDirectory())
293
+ .filter((entry) => entry.isDirectory() && !/^\.claim-[0-9a-f-]{36}$/.test(entry.name))
290
294
  .map((entry) => this.read(entry.name)));
291
295
  return records.sort((left, right) => left.acceptedAt.localeCompare(right.acceptedAt) ||
292
296
  left.triggerId.localeCompare(right.triggerId));
@@ -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.0",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {