@nettee/beacon 0.1.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.
Files changed (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +163 -0
  3. package/deploy/io.nettee.beacon.plist.example +32 -0
  4. package/dist/cli-app.js +71 -0
  5. package/dist/cli.js +21 -0
  6. package/dist/config/global.js +84 -0
  7. package/dist/config/profile.js +126 -0
  8. package/dist/config/registry.js +12 -0
  9. package/dist/config/secrets.js +68 -0
  10. package/dist/config/yaml.js +33 -0
  11. package/dist/doctor.js +29 -0
  12. package/dist/domain/types.js +18 -0
  13. package/dist/feishu/doctor.js +21 -0
  14. package/dist/feishu/gateway.js +129 -0
  15. package/dist/feishu/intake.js +67 -0
  16. package/dist/feishu/message-gateway.js +1 -0
  17. package/dist/feishu/message-pipeline.js +14 -0
  18. package/dist/feishu/reply-experiment.js +111 -0
  19. package/dist/feishu/trigger-input.js +46 -0
  20. package/dist/manual-trigger.js +59 -0
  21. package/dist/message/gateway.js +1 -0
  22. package/dist/outcome/doctor.js +29 -0
  23. package/dist/outcome/server.js +108 -0
  24. package/dist/outcome/submit.js +47 -0
  25. package/dist/run/create-pi-orchestrator.js +21 -0
  26. package/dist/run/orchestrator.js +269 -0
  27. package/dist/run/profile-runner.js +39 -0
  28. package/dist/run/queue.js +46 -0
  29. package/dist/runtime/pi-doctor.js +14 -0
  30. package/dist/runtime/pi-outcome-extension.js +53 -0
  31. package/dist/runtime/pi-rpc.js +247 -0
  32. package/dist/schedule/cron.js +48 -0
  33. package/dist/schedule/cursor-store.js +105 -0
  34. package/dist/schedule/loop.js +45 -0
  35. package/dist/schedule/reconciler.js +41 -0
  36. package/dist/service.js +97 -0
  37. package/dist/state/trigger-store.js +234 -0
  38. package/dist/version.js +9 -0
  39. package/examples/config.yaml +12 -0
  40. package/examples/profiles/example/profile.yaml +13 -0
  41. package/examples/profiles/example/prompt.md +1 -0
  42. package/examples/secrets.json.example +11 -0
  43. package/package.json +61 -0
@@ -0,0 +1,14 @@
1
+ import { runPiAgent } from "./pi-rpc.js";
2
+ export async function runPiDoctor(provider, model) {
3
+ const result = await runPiAgent({
4
+ prompt: "Reply with exactly: BEACON_PI_RPC_OK",
5
+ workspace: process.cwd(),
6
+ provider,
7
+ model,
8
+ systemPrompt: "Follow the user's instruction exactly. Do not use tools.",
9
+ });
10
+ if (result.text !== "BEACON_PI_RPC_OK") {
11
+ throw new Error(`Pi RPC smoke test returned an unexpected response: ${JSON.stringify(result.text)}`);
12
+ }
13
+ console.log(`[beacon] Pi RPC ready provider=${result.provider} model=${result.model}`);
14
+ }
@@ -0,0 +1,53 @@
1
+ import { spawn } from "node:child_process";
2
+ async function invokeBeaconCli(text, signal) {
3
+ const cliPath = process.env.BEACON_CLI_PATH;
4
+ if (!cliPath)
5
+ throw new Error("BEACON_CLI_PATH is missing from this Agent Run");
6
+ await new Promise((resolve, reject) => {
7
+ const child = spawn(process.execPath, [cliPath, "outcome", "submit"], {
8
+ env: process.env,
9
+ stdio: ["pipe", "pipe", "pipe"],
10
+ signal,
11
+ });
12
+ let stderr = "";
13
+ child.stderr.setEncoding("utf8");
14
+ child.stderr.on("data", (chunk) => {
15
+ stderr = (stderr + chunk).slice(-64 * 1024);
16
+ });
17
+ child.once("error", reject);
18
+ child.once("exit", (code, exitSignal) => {
19
+ if (code === 0) {
20
+ resolve();
21
+ return;
22
+ }
23
+ reject(new Error(`beacon outcome submit failed (code=${String(code)} signal=${String(exitSignal)}): ${stderr.trim()}`));
24
+ });
25
+ child.stdin.end(text);
26
+ });
27
+ }
28
+ export default function registerOutcomeTool(pi) {
29
+ pi.registerTool({
30
+ name: "submit_final_outcome",
31
+ label: "Submit Final Outcome",
32
+ description: "Submit the exact final response that Beacon must deliver to the user. Call this once after completing the task.",
33
+ parameters: {
34
+ type: "object",
35
+ properties: {
36
+ text: {
37
+ type: "string",
38
+ minLength: 1,
39
+ description: "The complete user-facing final response.",
40
+ },
41
+ },
42
+ required: ["text"],
43
+ additionalProperties: false,
44
+ },
45
+ async execute(_toolCallId, params, signal) {
46
+ await invokeBeaconCli(params.text, signal);
47
+ return {
48
+ content: [{ type: "text", text: "Final Outcome accepted by Beacon." }],
49
+ details: { submitted: true },
50
+ };
51
+ },
52
+ });
53
+ }
@@ -0,0 +1,247 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { fileURLToPath } from "node:url";
4
+ export class PiRuntimeError extends Error {
5
+ code;
6
+ constructor(code, message, options) {
7
+ super(message, options);
8
+ this.code = code;
9
+ this.name = "PiRuntimeError";
10
+ }
11
+ }
12
+ function isObject(value) {
13
+ return typeof value === "object" && value !== null;
14
+ }
15
+ function isRpcResponse(value) {
16
+ return (isObject(value) &&
17
+ value.type === "response" &&
18
+ typeof value.success === "boolean");
19
+ }
20
+ function isAssistantMessage(value) {
21
+ return (isObject(value) &&
22
+ value.role === "assistant" &&
23
+ Array.isArray(value.content) &&
24
+ typeof value.provider === "string" &&
25
+ typeof value.model === "string" &&
26
+ typeof value.stopReason === "string");
27
+ }
28
+ function isMessageEndEvent(value) {
29
+ return (isObject(value) &&
30
+ value.type === "message_end" &&
31
+ isAssistantMessage(value.message));
32
+ }
33
+ function isAgentSettledEvent(value) {
34
+ return isObject(value) && value.type === "agent_settled";
35
+ }
36
+ function collectText(message) {
37
+ return message.content
38
+ .filter((block) => isObject(block) &&
39
+ block.type === "text" &&
40
+ typeof block.text === "string")
41
+ .map((block) => block.text)
42
+ .join("")
43
+ .trim();
44
+ }
45
+ function buildArguments(request) {
46
+ const args = ["--mode", "rpc", "--no-session", "--no-approve"];
47
+ if (request.provider)
48
+ args.push("--provider", request.provider);
49
+ if (request.model)
50
+ args.push("--model", request.model);
51
+ if (request.systemPrompt)
52
+ args.push("--system-prompt", request.systemPrompt);
53
+ if (request.outcome) {
54
+ args.push("--extension", fileURLToPath(new URL("../../dist/runtime/pi-outcome-extension.js", import.meta.url)));
55
+ }
56
+ return args;
57
+ }
58
+ const inheritedEnvironmentKeys = [
59
+ "PATH",
60
+ "HOME",
61
+ "USER",
62
+ "LOGNAME",
63
+ "SHELL",
64
+ "TMPDIR",
65
+ "LANG",
66
+ "LC_ALL",
67
+ "TERM",
68
+ "COLORTERM",
69
+ "SSH_AUTH_SOCK",
70
+ "XDG_CONFIG_HOME",
71
+ "XDG_CACHE_HOME",
72
+ "GH_CONFIG_DIR",
73
+ "PI_CODING_AGENT_DIR",
74
+ "PI_CODING_AGENT_SESSION_DIR",
75
+ "PI_PACKAGE_DIR",
76
+ "PI_OFFLINE",
77
+ "PI_TELEMETRY",
78
+ ];
79
+ function buildPiEnvironment(outcome, configured = {}) {
80
+ const environment = {};
81
+ for (const key of inheritedEnvironmentKeys) {
82
+ const value = process.env[key];
83
+ if (value !== undefined)
84
+ environment[key] = value;
85
+ }
86
+ for (const [key, value] of Object.entries(configured)) {
87
+ if (!inheritedEnvironmentKeys.includes(key)) {
88
+ throw new Error(`Pi environment key is not allowlisted: ${key}`);
89
+ }
90
+ if (value !== undefined)
91
+ environment[key] = value;
92
+ }
93
+ if (outcome) {
94
+ environment.BEACON_OUTCOME_SOCKET = outcome.socketPath;
95
+ environment.BEACON_RUN_TOKEN = outcome.runToken;
96
+ environment.BEACON_CLI_PATH = outcome.cliPath;
97
+ }
98
+ return environment;
99
+ }
100
+ function describeExit(code, signal, stderr) {
101
+ const detail = stderr.trim();
102
+ return new PiRuntimeError("runtime_exit_failed", `Pi process exited before the Run settled (code=${String(code)} signal=${String(signal)})${detail ? `: ${detail}` : ""}`);
103
+ }
104
+ async function stopProcess(child, terminateGraceMs) {
105
+ if (child.exitCode !== null || child.signalCode !== null)
106
+ return;
107
+ child.kill("SIGTERM");
108
+ await new Promise((resolve) => {
109
+ const timer = setTimeout(() => {
110
+ child.kill("SIGKILL");
111
+ resolve();
112
+ }, terminateGraceMs);
113
+ child.once("exit", () => {
114
+ clearTimeout(timer);
115
+ resolve();
116
+ });
117
+ });
118
+ }
119
+ export async function runPiAgent(request, options = {}) {
120
+ if (!request.prompt.trim())
121
+ throw new Error("Pi Run prompt must not be empty");
122
+ if (!request.workspace.trim())
123
+ throw new Error("Pi Run workspace must not be empty");
124
+ if ((request.provider === undefined) !== (request.model === undefined)) {
125
+ throw new Error("Pi Run provider and model must either both be set or both be omitted");
126
+ }
127
+ const executable = options.executable ?? "pi";
128
+ const timeoutMs = options.timeoutMs ?? 5 * 60_000;
129
+ const terminateGraceMs = options.terminateGraceMs ?? 1_000;
130
+ const maxFrameBytes = options.maxFrameBytes ?? 1024 * 1024;
131
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
132
+ throw new Error("Pi Run timeout must be a positive integer");
133
+ }
134
+ if (!Number.isSafeInteger(terminateGraceMs) || terminateGraceMs <= 0) {
135
+ throw new Error("Pi termination grace must be a positive integer");
136
+ }
137
+ if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
138
+ throw new Error("Pi RPC frame limit must be a positive integer");
139
+ }
140
+ const child = spawn(executable, buildArguments(request), {
141
+ cwd: request.workspace,
142
+ env: buildPiEnvironment(request.outcome, options.environment),
143
+ stdio: ["pipe", "pipe", "pipe"],
144
+ });
145
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
146
+ let stderr = "";
147
+ let finalMessage;
148
+ let promptAccepted = false;
149
+ let finished = false;
150
+ child.stderr.setEncoding("utf8");
151
+ child.stderr.on("data", (chunk) => {
152
+ stderr = (stderr + chunk).slice(-64 * 1024);
153
+ });
154
+ try {
155
+ return await new Promise((resolve, reject) => {
156
+ const fail = (error) => {
157
+ if (finished)
158
+ return;
159
+ finished = true;
160
+ clearTimeout(timer);
161
+ reject(error);
162
+ };
163
+ const timer = setTimeout(() => fail(new PiRuntimeError("runtime_timeout", `Pi Run timed out after ${timeoutMs}ms${stderr.trim() ? `: ${stderr.trim()}` : ""}`)), timeoutMs);
164
+ let frameBytes = 0;
165
+ child.stdout.on("data", (chunk) => {
166
+ for (const byte of chunk) {
167
+ if (byte === 10)
168
+ frameBytes = 0;
169
+ else
170
+ frameBytes += 1;
171
+ if (frameBytes > maxFrameBytes) {
172
+ fail(new PiRuntimeError("runtime_protocol_error", `Pi RPC frame exceeds ${maxFrameBytes} bytes`));
173
+ return;
174
+ }
175
+ }
176
+ });
177
+ child.once("error", (error) => fail(new PiRuntimeError("runtime_spawn_failed", `Failed to start Pi: ${error.message}`, {
178
+ cause: error,
179
+ })));
180
+ child.once("exit", (code, signal) => {
181
+ if (!finished)
182
+ fail(describeExit(code, signal, stderr));
183
+ });
184
+ lines.on("line", (line) => {
185
+ if (finished)
186
+ return;
187
+ let value;
188
+ try {
189
+ value = JSON.parse(line);
190
+ }
191
+ catch {
192
+ fail(new PiRuntimeError("runtime_protocol_error", `Pi emitted invalid RPC JSON: ${line}`));
193
+ return;
194
+ }
195
+ if (isRpcResponse(value) && value.id === "run-prompt") {
196
+ if (!value.success) {
197
+ fail(new PiRuntimeError("runtime_protocol_error", `Pi rejected the prompt: ${value.error ?? "unknown RPC error"}`));
198
+ return;
199
+ }
200
+ promptAccepted = true;
201
+ }
202
+ if (isMessageEndEvent(value)) {
203
+ finalMessage = value.message;
204
+ return;
205
+ }
206
+ if (!isAgentSettledEvent(value))
207
+ return;
208
+ if (!promptAccepted) {
209
+ fail(new PiRuntimeError("runtime_protocol_error", "Pi settled without accepting the prompt RPC command"));
210
+ return;
211
+ }
212
+ if (!finalMessage) {
213
+ fail(new PiRuntimeError("runtime_protocol_error", "Pi settled without an assistant message_end event"));
214
+ return;
215
+ }
216
+ if (finalMessage.stopReason !== "stop") {
217
+ fail(new PiRuntimeError("runtime_exit_failed", `Pi ended with stopReason=${finalMessage.stopReason}${finalMessage.errorMessage ? `: ${finalMessage.errorMessage}` : ""}`));
218
+ return;
219
+ }
220
+ try {
221
+ const text = collectText(finalMessage);
222
+ if (!request.outcome && !text) {
223
+ fail(new Error("Pi completed without a textual final response"));
224
+ return;
225
+ }
226
+ const result = {
227
+ text,
228
+ provider: finalMessage.provider,
229
+ model: finalMessage.model,
230
+ };
231
+ finished = true;
232
+ clearTimeout(timer);
233
+ resolve(result);
234
+ }
235
+ catch (error) {
236
+ fail(error instanceof Error ? error : new Error(String(error)));
237
+ }
238
+ });
239
+ child.stdin.on("error", (error) => fail(new PiRuntimeError("runtime_protocol_error", `Failed to write Pi RPC command: ${error.message}`, { cause: error })));
240
+ child.stdin.write(`${JSON.stringify({ id: "run-prompt", type: "prompt", message: request.prompt })}\n`);
241
+ });
242
+ }
243
+ finally {
244
+ lines.close();
245
+ await stopProcess(child, terminateGraceMs);
246
+ }
247
+ }
@@ -0,0 +1,48 @@
1
+ import { CronExpressionParser } from "cron-parser";
2
+ export function occurrencesBetween(cron, timezone, afterExclusive, throughInclusive, limit) {
3
+ if (cron.trim().split(/\s+/).length !== 5) {
4
+ throw new Error("Schedule cron must contain exactly five fields");
5
+ }
6
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
7
+ throw new Error("Occurrence limit must be a positive integer");
8
+ }
9
+ const expression = CronExpressionParser.parse(`0 ${cron}`, {
10
+ currentDate: afterExclusive,
11
+ endDate: throughInclusive,
12
+ tz: timezone,
13
+ strict: true,
14
+ });
15
+ const occurrences = [];
16
+ while (true) {
17
+ let next;
18
+ try {
19
+ next = expression.next().toDate();
20
+ }
21
+ catch (error) {
22
+ if (error instanceof Error &&
23
+ /Out of the time span range|No more executions/.test(error.message)) {
24
+ break;
25
+ }
26
+ throw error;
27
+ }
28
+ if (next > throughInclusive)
29
+ break;
30
+ occurrences.push(next);
31
+ if (occurrences.length > limit) {
32
+ throw new Error(`Schedule occurrence enumeration exceeds limit ${limit}`);
33
+ }
34
+ }
35
+ return occurrences;
36
+ }
37
+ export function nextOccurrence(cron, timezone, afterExclusive) {
38
+ if (cron.trim().split(/\s+/).length !== 5) {
39
+ throw new Error("Schedule cron must contain exactly five fields");
40
+ }
41
+ return CronExpressionParser.parse(`0 ${cron}`, {
42
+ currentDate: afterExclusive,
43
+ tz: timezone,
44
+ strict: true,
45
+ })
46
+ .next()
47
+ .toDate();
48
+ }
@@ -0,0 +1,105 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, open, readFile, rename } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ const cursorSchema = z
6
+ .object({
7
+ version: z.literal(1),
8
+ scheduleId: z.string().min(1),
9
+ through: z.string().datetime({ offset: true }),
10
+ })
11
+ .strict();
12
+ export class ScheduleCursorStore {
13
+ root;
14
+ constructor(profileDirectory) {
15
+ this.root = join(profileDirectory, "state", "schedules");
16
+ }
17
+ path(scheduleId) {
18
+ return join(this.root, `${scheduleId}.json`);
19
+ }
20
+ async prepare() {
21
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
22
+ await chmod(join(this.root, ".."), 0o700);
23
+ await chmod(this.root, 0o700);
24
+ }
25
+ async read(scheduleId) {
26
+ await this.prepare();
27
+ let raw;
28
+ try {
29
+ raw = await readFile(this.path(scheduleId), "utf8");
30
+ }
31
+ catch (error) {
32
+ if (error.code === "ENOENT")
33
+ return undefined;
34
+ throw new Error(`Cannot read Schedule cursor ${scheduleId}`, {
35
+ cause: error,
36
+ });
37
+ }
38
+ try {
39
+ return cursorSchema.parse(JSON.parse(raw));
40
+ }
41
+ catch (error) {
42
+ throw new Error(`Invalid Schedule cursor ${scheduleId}`, {
43
+ cause: error,
44
+ });
45
+ }
46
+ }
47
+ async write(cursor, exclusive = false) {
48
+ await this.prepare();
49
+ const target = this.path(cursor.scheduleId);
50
+ const temporary = exclusive
51
+ ? target
52
+ : join(this.root, `.${cursor.scheduleId}-${randomUUID()}.tmp`);
53
+ const handle = await open(temporary, exclusive ? "wx" : "wx", 0o600);
54
+ try {
55
+ await handle.writeFile(`${JSON.stringify(cursor, null, 2)}\n`);
56
+ await handle.sync();
57
+ }
58
+ finally {
59
+ await handle.close();
60
+ }
61
+ if (!exclusive)
62
+ await rename(temporary, target);
63
+ const directory = await open(this.root, "r");
64
+ try {
65
+ await directory.sync();
66
+ }
67
+ finally {
68
+ await directory.close();
69
+ }
70
+ }
71
+ async initialize(scheduleId, through) {
72
+ const cursor = cursorSchema.parse({
73
+ version: 1,
74
+ scheduleId,
75
+ through: through.toISOString(),
76
+ });
77
+ try {
78
+ await this.write(cursor, true);
79
+ return cursor;
80
+ }
81
+ catch (error) {
82
+ if (error.code !== "EEXIST")
83
+ throw error;
84
+ const existing = await this.read(scheduleId);
85
+ if (!existing)
86
+ throw new Error(`Schedule cursor disappeared: ${scheduleId}`);
87
+ return existing;
88
+ }
89
+ }
90
+ async advance(scheduleId, through) {
91
+ const current = await this.read(scheduleId);
92
+ if (!current)
93
+ throw new Error(`Cannot advance missing Schedule cursor ${scheduleId}`);
94
+ if (through.toISOString() < current.through) {
95
+ throw new Error(`Schedule cursor cannot move backwards: ${scheduleId}`);
96
+ }
97
+ const next = cursorSchema.parse({
98
+ version: 1,
99
+ scheduleId,
100
+ through: through.toISOString(),
101
+ });
102
+ await this.write(next);
103
+ return next;
104
+ }
105
+ }
@@ -0,0 +1,45 @@
1
+ import { nextOccurrence } from "./cron.js";
2
+ const MAX_TIMER_MS = 2_147_000_000;
3
+ export class ScheduleLoop {
4
+ profile;
5
+ reconciler;
6
+ onFatal;
7
+ timer;
8
+ stopped = false;
9
+ active;
10
+ constructor(profile, reconciler, onFatal) {
11
+ this.profile = profile;
12
+ this.reconciler = reconciler;
13
+ this.onFatal = onFatal;
14
+ }
15
+ start(after = new Date()) {
16
+ if (this.stopped || this.profile.schedules.length === 0)
17
+ return;
18
+ const next = this.profile.schedules
19
+ .map((schedule) => nextOccurrence(schedule.cron, schedule.timezone, after))
20
+ .sort((left, right) => left.getTime() - right.getTime())[0];
21
+ if (!next)
22
+ return;
23
+ const delay = Math.max(0, Math.min(MAX_TIMER_MS, next.getTime() - Date.now()));
24
+ this.timer = setTimeout(() => {
25
+ const now = new Date();
26
+ const active = this.reconciler
27
+ .reconcile(now)
28
+ .then(() => this.start(now))
29
+ .catch((error) => this.onFatal(error instanceof Error ? error : new Error(String(error))))
30
+ .finally(() => {
31
+ if (this.active === active)
32
+ this.active = undefined;
33
+ });
34
+ this.active = active;
35
+ }, delay);
36
+ }
37
+ stop() {
38
+ this.stopped = true;
39
+ if (this.timer)
40
+ clearTimeout(this.timer);
41
+ }
42
+ async drain() {
43
+ await this.active;
44
+ }
45
+ }
@@ -0,0 +1,41 @@
1
+ import { occurrencesBetween } from "./cron.js";
2
+ export class ScheduleReconciler {
3
+ options;
4
+ constructor(options) {
5
+ this.options = options;
6
+ }
7
+ async reconcile(now) {
8
+ for (const schedule of this.options.profile.schedules) {
9
+ let cursor = await this.options.cursors.read(schedule.id);
10
+ if (!cursor) {
11
+ await this.options.cursors.initialize(schedule.id, now);
12
+ continue;
13
+ }
14
+ const occurrences = occurrencesBetween(schedule.cron, schedule.timezone, new Date(cursor.through), now, this.options.maxOccurrences);
15
+ const latest = occurrences.at(-1);
16
+ if (!latest)
17
+ continue;
18
+ const scheduledFor = latest.toISOString();
19
+ const claim = await this.options.triggers.claim({
20
+ sourceKey: ["schedule", schedule.id, scheduledFor],
21
+ target: { kind: "chat", chatId: schedule.delivery.chatId },
22
+ ingress: {
23
+ scheduleId: schedule.id,
24
+ scheduledFor,
25
+ text: schedule.input,
26
+ },
27
+ });
28
+ await this.options.cursors.advance(schedule.id, latest);
29
+ cursor = { version: 1, scheduleId: schedule.id, through: scheduledFor };
30
+ void cursor;
31
+ if (!claim.created)
32
+ continue;
33
+ await this.options.process(claim.record.triggerKey, async () => ({
34
+ kind: "schedule",
35
+ scheduleId: schedule.id,
36
+ scheduledFor,
37
+ text: schedule.input,
38
+ }));
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,97 @@
1
+ import { loadGlobalConfig } from "./config/global.js";
2
+ import { loadProfileRegistry } from "./config/registry.js";
3
+ import { loadFeishuCredentials } from "./config/secrets.js";
4
+ import { createFeishuGateway } from "./feishu/gateway.js";
5
+ import { createFeishuMessagePipeline } from "./feishu/message-pipeline.js";
6
+ import { startOutcomeServer } from "./outcome/server.js";
7
+ import { createPiRunOrchestrator } from "./run/create-pi-orchestrator.js";
8
+ import { RunQueue } from "./run/queue.js";
9
+ import { ScheduleCursorStore } from "./schedule/cursor-store.js";
10
+ import { ScheduleLoop } from "./schedule/loop.js";
11
+ import { ScheduleReconciler } from "./schedule/reconciler.js";
12
+ import { TriggerStore } from "./state/trigger-store.js";
13
+ function shutdownController() {
14
+ let resolve;
15
+ const promise = new Promise((done) => {
16
+ resolve = done;
17
+ });
18
+ const stop = () => resolve();
19
+ process.once("SIGINT", stop);
20
+ process.once("SIGTERM", stop);
21
+ return {
22
+ promise,
23
+ resolve,
24
+ close() {
25
+ process.off("SIGINT", stop);
26
+ process.off("SIGTERM", stop);
27
+ },
28
+ };
29
+ }
30
+ export async function runBeacon(configPath) {
31
+ const global = await loadGlobalConfig(configPath);
32
+ const profiles = await loadProfileRegistry(global.profilesDirectory);
33
+ const credentials = await Promise.all(profiles.map((profile) => loadFeishuCredentials(profile.id, global.secretsPath)));
34
+ const outcomes = await startOutcomeServer();
35
+ const queue = new RunQueue(global.runs.maxConcurrent, global.runs.maxQueued);
36
+ const shutdown = shutdownController();
37
+ let rejectFatal;
38
+ const fatal = new Promise((_resolve, reject) => {
39
+ rejectFatal = reject;
40
+ });
41
+ const messagePipelines = [];
42
+ const loops = [];
43
+ const contexts = profiles.map((profile, index) => {
44
+ const profileCredentials = credentials[index];
45
+ const gateway = createFeishuGateway(profileCredentials);
46
+ const store = new TriggerStore(profile.directory, profile.id);
47
+ const orchestrator = createPiRunOrchestrator({
48
+ config: global,
49
+ profile,
50
+ store,
51
+ queue,
52
+ outcomes,
53
+ delivery: gateway,
54
+ });
55
+ const messagePipeline = createFeishuMessagePipeline({
56
+ gateway,
57
+ store,
58
+ process: (triggerKey, normalize) => orchestrator.process(triggerKey, normalize),
59
+ onFatal: rejectFatal,
60
+ });
61
+ messagePipelines.push(messagePipeline);
62
+ const reconciler = new ScheduleReconciler({
63
+ profile,
64
+ triggers: store,
65
+ cursors: new ScheduleCursorStore(profile.directory),
66
+ maxOccurrences: global.scheduler.maxOccurrencesPerReconciliation,
67
+ process: (triggerKey, normalize) => orchestrator.process(triggerKey, normalize),
68
+ });
69
+ const loop = new ScheduleLoop(profile, reconciler, rejectFatal);
70
+ loops.push(loop);
71
+ return { profile, messagePipeline, orchestrator, reconciler };
72
+ });
73
+ await Promise.all(contexts.map((context) => context.orchestrator.recover()));
74
+ const reconciliationTime = new Date();
75
+ await Promise.all(contexts.map((context) => context.reconciler.reconcile(reconciliationTime)));
76
+ for (const loop of loops)
77
+ loop.start(reconciliationTime);
78
+ const gateways = contexts.map(({ profile, messagePipeline }) => {
79
+ console.log(`[beacon] starting Profile id=${profile.id} runtime=${profile.runtime} provider=${profile.model.provider} model=${profile.model.id}`);
80
+ return messagePipeline.run(shutdown.promise);
81
+ });
82
+ try {
83
+ await Promise.race([shutdown.promise, fatal, ...gateways]);
84
+ }
85
+ finally {
86
+ shutdown.resolve();
87
+ for (const loop of loops)
88
+ loop.stop();
89
+ await Promise.allSettled(gateways);
90
+ await Promise.allSettled([
91
+ ...messagePipelines.map((pipeline) => pipeline.drain()),
92
+ ...loops.map((loop) => loop.drain()),
93
+ ]);
94
+ shutdown.close();
95
+ await outcomes.close();
96
+ }
97
+ }