@pinet/broker-core 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.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/agent-messaging.d.ts +47 -0
- package/dist/agent-messaging.js +176 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +59 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/leader.d.ts +29 -0
- package/dist/leader.js +95 -0
- package/dist/mail-classification.d.ts +17 -0
- package/dist/mail-classification.js +103 -0
- package/dist/maintenance.d.ts +50 -0
- package/dist/maintenance.js +134 -0
- package/dist/message-send.d.ts +31 -0
- package/dist/message-send.js +75 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.js +29 -0
- package/dist/raw-tcp-loopback.d.ts +2 -0
- package/dist/raw-tcp-loopback.js +39 -0
- package/dist/router.d.ts +60 -0
- package/dist/router.js +336 -0
- package/dist/schema.d.ts +155 -0
- package/dist/schema.js +3076 -0
- package/dist/types.d.ts +312 -0
- package/dist/types.js +16 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Will Porcellini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @pinet/broker-core
|
|
2
|
+
|
|
3
|
+
Transport-neutral broker kernel primitives for the `extensions` repo.
|
|
4
|
+
|
|
5
|
+
## What lives here
|
|
6
|
+
|
|
7
|
+
- broker domain types
|
|
8
|
+
- broker SQLite state and persistence
|
|
9
|
+
- routing and backlog maintenance logic
|
|
10
|
+
- direct/broadcast agent messaging helpers
|
|
11
|
+
- broker auth / lock / path / loopback utilities
|
|
12
|
+
|
|
13
|
+
## What stays out of scope
|
|
14
|
+
|
|
15
|
+
- Slack adapter and event normalization
|
|
16
|
+
- Slack tools, Home tabs, canvases, and manifest concerns
|
|
17
|
+
- Pi extension command/tool wiring
|
|
18
|
+
- broker runtime orchestration and RALPH UI flows
|
|
19
|
+
- follower runtime and single-player runtime glue
|
|
20
|
+
|
|
21
|
+
## Publishing
|
|
22
|
+
|
|
23
|
+
This package is part of the full npm publish set tracked in
|
|
24
|
+
[`../plans/npm-publish.md`](../plans/npm-publish.md). Use the GitHub Actions
|
|
25
|
+
workflow's default dry-run/readiness path for validation; do not publish, tag, or
|
|
26
|
+
bump versions without explicit maintainer release approval.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AgentInfo, BrokerMessage } from "./types.js";
|
|
2
|
+
export interface AgentMessageStorage {
|
|
3
|
+
getAgents(): AgentInfo[];
|
|
4
|
+
getThread(threadId: string): {
|
|
5
|
+
threadId: string;
|
|
6
|
+
} | null;
|
|
7
|
+
createThread(threadId: string, source: string, channel: string, ownerAgent: string | null): void;
|
|
8
|
+
insertMessage(threadId: string, source: string, direction: "inbound" | "outbound", sender: string, body: string, targetAgentIds: string[], metadata?: Record<string, unknown>): BrokerMessage;
|
|
9
|
+
}
|
|
10
|
+
export interface AgentDispatchTarget {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
export interface DirectAgentDispatchInput {
|
|
15
|
+
senderAgentId: string;
|
|
16
|
+
senderAgentName: string;
|
|
17
|
+
target: string;
|
|
18
|
+
body: string;
|
|
19
|
+
metadata?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
export interface BroadcastAgentDispatchInput {
|
|
22
|
+
senderAgentId: string;
|
|
23
|
+
senderAgentName: string;
|
|
24
|
+
channel: string;
|
|
25
|
+
body: string;
|
|
26
|
+
metadata?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
export interface DirectAgentDispatchResult {
|
|
29
|
+
target: AgentDispatchTarget;
|
|
30
|
+
messageId: number;
|
|
31
|
+
threadId: string;
|
|
32
|
+
}
|
|
33
|
+
export interface BroadcastAgentDispatchResult {
|
|
34
|
+
channel: string;
|
|
35
|
+
targets: AgentDispatchTarget[];
|
|
36
|
+
messageIds: number[];
|
|
37
|
+
threadIds: string[];
|
|
38
|
+
}
|
|
39
|
+
export type AgentDispatchCallback = (target: AgentDispatchTarget, message: BrokerMessage, metadata: Record<string, unknown>) => void;
|
|
40
|
+
export declare function isBroadcastChannelTarget(target: string): boolean;
|
|
41
|
+
export declare function normalizeBroadcastChannel(channel: string): string | null;
|
|
42
|
+
export declare function getAgentBroadcastChannels(agent: Pick<AgentInfo, "metadata">): string[];
|
|
43
|
+
export declare function agentSubscribesToBroadcastChannel(agent: Pick<AgentInfo, "metadata">, channel: string): boolean;
|
|
44
|
+
export declare function resolveDirectAgentTarget(agents: AgentInfo[], target: string): AgentInfo | null;
|
|
45
|
+
export declare function resolveBroadcastTargets(agents: AgentInfo[], senderAgentId: string, channel: string): AgentInfo[];
|
|
46
|
+
export declare function dispatchDirectAgentMessage(storage: AgentMessageStorage, input: DirectAgentDispatchInput, onDispatch?: AgentDispatchCallback): DirectAgentDispatchResult;
|
|
47
|
+
export declare function dispatchBroadcastAgentMessage(storage: AgentMessageStorage, input: BroadcastAgentDispatchInput, onDispatch?: AgentDispatchCallback): BroadcastAgentDispatchResult;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { classifyPinetMail } from "./mail-classification.js";
|
|
2
|
+
function asRecord(value) {
|
|
3
|
+
return typeof value === "object" && value !== null ? value : null;
|
|
4
|
+
}
|
|
5
|
+
function asString(value) {
|
|
6
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
7
|
+
}
|
|
8
|
+
function extractAgentCapabilities(metadata) {
|
|
9
|
+
const record = asRecord(metadata);
|
|
10
|
+
const capabilitiesRecord = asRecord(record?.capabilities);
|
|
11
|
+
return {
|
|
12
|
+
repo: asString(capabilitiesRecord?.repo) ?? asString(record?.repo),
|
|
13
|
+
role: asString(capabilitiesRecord?.role) ?? asString(record?.role),
|
|
14
|
+
tools: asStringArray(capabilitiesRecord?.tools),
|
|
15
|
+
tags: asStringArray(capabilitiesRecord?.tags),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function buildAgentCapabilityTags(capabilities) {
|
|
19
|
+
const tags = new Set();
|
|
20
|
+
if (capabilities.role)
|
|
21
|
+
tags.add(`role:${capabilities.role}`);
|
|
22
|
+
if (capabilities.repo)
|
|
23
|
+
tags.add(`repo:${capabilities.repo}`);
|
|
24
|
+
for (const tool of capabilities.tools ?? []) {
|
|
25
|
+
tags.add(`tool:${tool}`);
|
|
26
|
+
}
|
|
27
|
+
for (const tag of capabilities.tags ?? []) {
|
|
28
|
+
tags.add(tag);
|
|
29
|
+
}
|
|
30
|
+
return [...tags];
|
|
31
|
+
}
|
|
32
|
+
function asStringArray(value) {
|
|
33
|
+
if (!Array.isArray(value))
|
|
34
|
+
return [];
|
|
35
|
+
return value
|
|
36
|
+
.filter((item) => typeof item === "string")
|
|
37
|
+
.map((item) => item.trim())
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
function normalizeChannelName(value) {
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
if (!trimmed)
|
|
43
|
+
return null;
|
|
44
|
+
const withoutHash = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
|
|
45
|
+
const normalized = withoutHash.trim().toLowerCase();
|
|
46
|
+
return normalized.length > 0 ? normalized : null;
|
|
47
|
+
}
|
|
48
|
+
function addChannel(set, rawValue) {
|
|
49
|
+
const normalized = normalizeChannelName(rawValue);
|
|
50
|
+
if (!normalized)
|
|
51
|
+
return;
|
|
52
|
+
set.add(normalized);
|
|
53
|
+
if (normalized.startsWith("channel:") || normalized.startsWith("topic:")) {
|
|
54
|
+
const derived = normalized.slice(normalized.indexOf(":") + 1).trim();
|
|
55
|
+
if (derived) {
|
|
56
|
+
set.add(derived);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function ensurePairThread(storage, senderAgentId, targetAgentId) {
|
|
61
|
+
const threadId = `a2a:${senderAgentId}:${targetAgentId}`;
|
|
62
|
+
if (!storage.getThread(threadId)) {
|
|
63
|
+
storage.createThread(threadId, "agent", "", senderAgentId);
|
|
64
|
+
}
|
|
65
|
+
return threadId;
|
|
66
|
+
}
|
|
67
|
+
function buildAgentMessageMetadata(senderAgentName, body, metadata, broadcastChannel) {
|
|
68
|
+
const baseMetadata = {
|
|
69
|
+
...metadata,
|
|
70
|
+
senderAgent: senderAgentName,
|
|
71
|
+
a2a: true,
|
|
72
|
+
...(broadcastChannel ? { broadcast: true, broadcastChannel } : {}),
|
|
73
|
+
};
|
|
74
|
+
const classification = classifyPinetMail({ source: "agent", body, metadata: baseMetadata });
|
|
75
|
+
return {
|
|
76
|
+
...baseMetadata,
|
|
77
|
+
pinetMailClass: classification.class,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function deliverAgentMessage(storage, senderAgentId, target, body, metadata, onDispatch) {
|
|
81
|
+
const threadId = ensurePairThread(storage, senderAgentId, target.id);
|
|
82
|
+
const msg = storage.insertMessage(threadId, "agent", "inbound", senderAgentId, body, [target.id], metadata);
|
|
83
|
+
onDispatch?.(target, msg, metadata);
|
|
84
|
+
return { threadId, messageId: msg.id };
|
|
85
|
+
}
|
|
86
|
+
export function isBroadcastChannelTarget(target) {
|
|
87
|
+
return target.trim().startsWith("#");
|
|
88
|
+
}
|
|
89
|
+
export function normalizeBroadcastChannel(channel) {
|
|
90
|
+
return normalizeChannelName(channel);
|
|
91
|
+
}
|
|
92
|
+
export function getAgentBroadcastChannels(agent) {
|
|
93
|
+
const subscriptions = new Set(["all"]);
|
|
94
|
+
const metadata = asRecord(agent.metadata);
|
|
95
|
+
const capabilities = extractAgentCapabilities(metadata);
|
|
96
|
+
if (capabilities.repo) {
|
|
97
|
+
addChannel(subscriptions, capabilities.repo);
|
|
98
|
+
}
|
|
99
|
+
const role = capabilities.role?.trim().toLowerCase();
|
|
100
|
+
if (role) {
|
|
101
|
+
addChannel(subscriptions, `role:${role}`);
|
|
102
|
+
}
|
|
103
|
+
if (role !== "broker") {
|
|
104
|
+
addChannel(subscriptions, "standup");
|
|
105
|
+
}
|
|
106
|
+
for (const tag of buildAgentCapabilityTags(capabilities)) {
|
|
107
|
+
addChannel(subscriptions, tag);
|
|
108
|
+
}
|
|
109
|
+
for (const channel of asStringArray(metadata?.broadcastChannels)) {
|
|
110
|
+
addChannel(subscriptions, channel);
|
|
111
|
+
}
|
|
112
|
+
for (const channel of asStringArray(metadata?.channels)) {
|
|
113
|
+
addChannel(subscriptions, channel);
|
|
114
|
+
}
|
|
115
|
+
for (const topic of asStringArray(metadata?.topics)) {
|
|
116
|
+
addChannel(subscriptions, `topic:${topic}`);
|
|
117
|
+
}
|
|
118
|
+
return [...subscriptions].sort();
|
|
119
|
+
}
|
|
120
|
+
export function agentSubscribesToBroadcastChannel(agent, channel) {
|
|
121
|
+
const normalized = normalizeBroadcastChannel(channel);
|
|
122
|
+
if (!normalized)
|
|
123
|
+
return false;
|
|
124
|
+
return getAgentBroadcastChannels(agent).includes(normalized);
|
|
125
|
+
}
|
|
126
|
+
export function resolveDirectAgentTarget(agents, target) {
|
|
127
|
+
return (agents.find((agent) => agent.id === target) ??
|
|
128
|
+
agents.find((agent) => agent.name === target) ??
|
|
129
|
+
null);
|
|
130
|
+
}
|
|
131
|
+
export function resolveBroadcastTargets(agents, senderAgentId, channel) {
|
|
132
|
+
return agents
|
|
133
|
+
.filter((agent) => agent.id !== senderAgentId)
|
|
134
|
+
.filter((agent) => agentSubscribesToBroadcastChannel(agent, channel))
|
|
135
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
136
|
+
}
|
|
137
|
+
export function dispatchDirectAgentMessage(storage, input, onDispatch) {
|
|
138
|
+
const target = resolveDirectAgentTarget(storage.getAgents(), input.target);
|
|
139
|
+
if (!target) {
|
|
140
|
+
throw new Error(`Agent not found: ${input.target}`);
|
|
141
|
+
}
|
|
142
|
+
const resolvedTarget = { id: target.id, name: target.name };
|
|
143
|
+
const metadata = buildAgentMessageMetadata(input.senderAgentName, input.body, input.metadata);
|
|
144
|
+
const { threadId, messageId } = deliverAgentMessage(storage, input.senderAgentId, resolvedTarget, input.body, metadata, onDispatch);
|
|
145
|
+
return {
|
|
146
|
+
target: resolvedTarget,
|
|
147
|
+
messageId,
|
|
148
|
+
threadId,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export function dispatchBroadcastAgentMessage(storage, input, onDispatch) {
|
|
152
|
+
const normalizedChannel = normalizeBroadcastChannel(input.channel);
|
|
153
|
+
if (!normalizedChannel) {
|
|
154
|
+
throw new Error("Broadcast channel is required");
|
|
155
|
+
}
|
|
156
|
+
const agents = storage.getAgents();
|
|
157
|
+
const targets = resolveBroadcastTargets(agents, input.senderAgentId, normalizedChannel).map((agent) => ({ id: agent.id, name: agent.name }));
|
|
158
|
+
if (targets.length === 0) {
|
|
159
|
+
throw new Error(`No agents subscribed to #${normalizedChannel} other than the sender.`);
|
|
160
|
+
}
|
|
161
|
+
const broadcastChannel = `#${normalizedChannel}`;
|
|
162
|
+
const metadata = buildAgentMessageMetadata(input.senderAgentName, input.body, input.metadata, broadcastChannel);
|
|
163
|
+
const messageIds = [];
|
|
164
|
+
const threadIds = [];
|
|
165
|
+
for (const target of targets) {
|
|
166
|
+
const delivery = deliverAgentMessage(storage, input.senderAgentId, target, input.body, metadata, onDispatch);
|
|
167
|
+
messageIds.push(delivery.messageId);
|
|
168
|
+
threadIds.push(delivery.threadId);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
channel: broadcastChannel,
|
|
172
|
+
targets,
|
|
173
|
+
messageIds,
|
|
174
|
+
threadIds,
|
|
175
|
+
};
|
|
176
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface MeshSecretOptions {
|
|
2
|
+
meshSecret?: string | null;
|
|
3
|
+
meshSecretPath?: string | null;
|
|
4
|
+
}
|
|
5
|
+
export declare function readMeshSecret(secretPath?: string): string;
|
|
6
|
+
export declare function loadOrCreateMeshSecret(secretPath?: string): string;
|
|
7
|
+
export declare function resolveMeshSecret(options?: MeshSecretOptions): string | null;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { getDefaultMeshSecretPath } from "./paths.js";
|
|
5
|
+
function normalizeMeshSecret(value) {
|
|
6
|
+
const trimmed = value?.trim();
|
|
7
|
+
return trimmed && trimmed.length > 0 ? trimmed : null;
|
|
8
|
+
}
|
|
9
|
+
function getErrorCode(err) {
|
|
10
|
+
if (typeof err !== "object" || err == null || !("code" in err)) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const code = err.code;
|
|
14
|
+
return typeof code === "string" ? code : null;
|
|
15
|
+
}
|
|
16
|
+
export function readMeshSecret(secretPath = getDefaultMeshSecretPath()) {
|
|
17
|
+
const secret = normalizeMeshSecret(fs.readFileSync(secretPath, "utf-8"));
|
|
18
|
+
if (!secret) {
|
|
19
|
+
throw new Error(`Pinet mesh secret file is empty: ${secretPath}`);
|
|
20
|
+
}
|
|
21
|
+
return secret;
|
|
22
|
+
}
|
|
23
|
+
export function loadOrCreateMeshSecret(secretPath = getDefaultMeshSecretPath()) {
|
|
24
|
+
try {
|
|
25
|
+
return readMeshSecret(secretPath);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (getErrorCode(err) !== "ENOENT") {
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
|
|
33
|
+
const meshSecret = crypto.randomBytes(32).toString("hex");
|
|
34
|
+
try {
|
|
35
|
+
fs.writeFileSync(secretPath, `${meshSecret}\n`, {
|
|
36
|
+
encoding: "utf-8",
|
|
37
|
+
mode: 0o600,
|
|
38
|
+
flag: "wx",
|
|
39
|
+
});
|
|
40
|
+
return meshSecret;
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (getErrorCode(err) !== "EEXIST") {
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
return readMeshSecret(secretPath);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function resolveMeshSecret(options = {}) {
|
|
50
|
+
const explicitSecret = normalizeMeshSecret(options.meshSecret);
|
|
51
|
+
if (explicitSecret) {
|
|
52
|
+
return explicitSecret;
|
|
53
|
+
}
|
|
54
|
+
const meshSecretPath = options.meshSecretPath?.trim();
|
|
55
|
+
if (!meshSecretPath) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return readMeshSecret(meshSecretPath);
|
|
59
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./agent-messaging.js";
|
|
2
|
+
export * from "./auth.js";
|
|
3
|
+
export * from "./leader.js";
|
|
4
|
+
export * from "./maintenance.js";
|
|
5
|
+
export * from "./mail-classification.js";
|
|
6
|
+
export * from "./message-send.js";
|
|
7
|
+
export * from "./paths.js";
|
|
8
|
+
export * from "./raw-tcp-loopback.js";
|
|
9
|
+
export * from "./router.js";
|
|
10
|
+
export * from "./schema.js";
|
|
11
|
+
export * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./agent-messaging.js";
|
|
2
|
+
export * from "./auth.js";
|
|
3
|
+
export * from "./leader.js";
|
|
4
|
+
export * from "./maintenance.js";
|
|
5
|
+
export * from "./mail-classification.js";
|
|
6
|
+
export * from "./message-send.js";
|
|
7
|
+
export * from "./paths.js";
|
|
8
|
+
export * from "./raw-tcp-loopback.js";
|
|
9
|
+
export * from "./router.js";
|
|
10
|
+
export * from "./schema.js";
|
|
11
|
+
export * from "./types.js";
|
package/dist/leader.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare function defaultLockPath(): string;
|
|
2
|
+
/**
|
|
3
|
+
* Leader election via PID lock file.
|
|
4
|
+
*
|
|
5
|
+
* Only one broker process should run at a time. The leader writes its
|
|
6
|
+
* PID to the lock file. Stale locks (PID no longer running) are
|
|
7
|
+
* automatically reclaimed.
|
|
8
|
+
*/
|
|
9
|
+
export declare class LeaderLock {
|
|
10
|
+
private readonly lockPath;
|
|
11
|
+
private acquired;
|
|
12
|
+
constructor(lockPath?: string);
|
|
13
|
+
/**
|
|
14
|
+
* Try to acquire the lock. Returns true if this process is now the leader.
|
|
15
|
+
*/
|
|
16
|
+
tryAcquire(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Release the lock if we hold it.
|
|
19
|
+
*/
|
|
20
|
+
release(): void;
|
|
21
|
+
/**
|
|
22
|
+
* Check if this instance currently holds the lock.
|
|
23
|
+
*/
|
|
24
|
+
isLeader(): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Get the lock file path (for testing).
|
|
27
|
+
*/
|
|
28
|
+
getLockPath(): string;
|
|
29
|
+
}
|
package/dist/leader.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
export function defaultLockPath() {
|
|
5
|
+
return path.join(os.homedir(), ".pi", "pinet-broker.lock");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Leader election via PID lock file.
|
|
9
|
+
*
|
|
10
|
+
* Only one broker process should run at a time. The leader writes its
|
|
11
|
+
* PID to the lock file. Stale locks (PID no longer running) are
|
|
12
|
+
* automatically reclaimed.
|
|
13
|
+
*/
|
|
14
|
+
export class LeaderLock {
|
|
15
|
+
lockPath;
|
|
16
|
+
acquired = false;
|
|
17
|
+
constructor(lockPath) {
|
|
18
|
+
this.lockPath = lockPath ?? defaultLockPath();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Try to acquire the lock. Returns true if this process is now the leader.
|
|
22
|
+
*/
|
|
23
|
+
tryAcquire() {
|
|
24
|
+
if (this.acquired)
|
|
25
|
+
return true;
|
|
26
|
+
fs.mkdirSync(path.dirname(this.lockPath), { recursive: true });
|
|
27
|
+
// Check existing lock
|
|
28
|
+
if (fs.existsSync(this.lockPath)) {
|
|
29
|
+
const content = fs.readFileSync(this.lockPath, "utf-8").trim();
|
|
30
|
+
const existingPid = parseInt(content, 10);
|
|
31
|
+
if (!isNaN(existingPid) && isProcessRunning(existingPid)) {
|
|
32
|
+
// Another live process holds the lock
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
// Stale lock — remove it
|
|
36
|
+
fs.unlinkSync(this.lockPath);
|
|
37
|
+
}
|
|
38
|
+
// Write our PID atomically (write to temp, rename)
|
|
39
|
+
const pid = process.pid;
|
|
40
|
+
const tmpPath = `${this.lockPath}.${pid}.tmp`;
|
|
41
|
+
fs.writeFileSync(tmpPath, String(pid), "utf-8");
|
|
42
|
+
fs.renameSync(tmpPath, this.lockPath);
|
|
43
|
+
// Verify we actually won (guard against race)
|
|
44
|
+
const written = fs.readFileSync(this.lockPath, "utf-8").trim();
|
|
45
|
+
if (written !== String(pid)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
this.acquired = true;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Release the lock if we hold it.
|
|
53
|
+
*/
|
|
54
|
+
release() {
|
|
55
|
+
if (!this.acquired)
|
|
56
|
+
return;
|
|
57
|
+
try {
|
|
58
|
+
// Only remove if it's still our PID
|
|
59
|
+
if (fs.existsSync(this.lockPath)) {
|
|
60
|
+
const content = fs.readFileSync(this.lockPath, "utf-8").trim();
|
|
61
|
+
if (content === String(process.pid)) {
|
|
62
|
+
fs.unlinkSync(this.lockPath);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Best-effort cleanup
|
|
68
|
+
}
|
|
69
|
+
this.acquired = false;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Check if this instance currently holds the lock.
|
|
73
|
+
*/
|
|
74
|
+
isLeader() {
|
|
75
|
+
return this.acquired;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Get the lock file path (for testing).
|
|
79
|
+
*/
|
|
80
|
+
getLockPath() {
|
|
81
|
+
return this.lockPath;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Check if a process with the given PID is currently running.
|
|
86
|
+
*/
|
|
87
|
+
function isProcessRunning(pid) {
|
|
88
|
+
try {
|
|
89
|
+
process.kill(pid, 0);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const PINET_MAIL_CLASSES: readonly ["steering", "fwup", "maintenance_context"];
|
|
2
|
+
export type PinetMailClass = (typeof PINET_MAIL_CLASSES)[number];
|
|
3
|
+
export interface PinetMailClassificationInput {
|
|
4
|
+
source?: string | null;
|
|
5
|
+
threadId?: string | null;
|
|
6
|
+
sender?: string | null;
|
|
7
|
+
body?: string | null;
|
|
8
|
+
metadata?: Record<string, unknown> | null;
|
|
9
|
+
}
|
|
10
|
+
export interface PinetMailClassification {
|
|
11
|
+
class: PinetMailClass;
|
|
12
|
+
reason: string;
|
|
13
|
+
explicit: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function normalizePinetMailClass(value: unknown): PinetMailClass | null;
|
|
16
|
+
export declare function classifyPinetMail(input: PinetMailClassificationInput): PinetMailClassification;
|
|
17
|
+
export declare function formatPinetMailClassLabel(mailClass: PinetMailClass): string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export const PINET_MAIL_CLASSES = ["steering", "fwup", "maintenance_context"];
|
|
2
|
+
const EXPLICIT_CLASS_KEYS = [
|
|
3
|
+
"pinetMailClass",
|
|
4
|
+
"pinet_mail_class",
|
|
5
|
+
"mailClass",
|
|
6
|
+
"mail_class",
|
|
7
|
+
"mailKind",
|
|
8
|
+
"mail_kind",
|
|
9
|
+
"classification",
|
|
10
|
+
];
|
|
11
|
+
const STEERING_PATTERNS = [
|
|
12
|
+
/\back(?:\/|\s*)work(?:\/|\s*)ask(?:\/|\s*)report\b/i,
|
|
13
|
+
/\back briefly\b/i,
|
|
14
|
+
/\bplease (?:take|continue|implement|fix|review|inspect|reassign|handle|work on)\b/i,
|
|
15
|
+
/\b(?:new|fresh) (?:implementation )?(?:lane|task|worktree)\b/i,
|
|
16
|
+
/\b(?:task|issue|worktree setup|scope|workflow|acceptance criteria|constraints?)\s*:/i,
|
|
17
|
+
/\b(?:take|continue|implement|fix|review|inspect|reassign|handle|work on)\s+(?:issue|pr)\s*#\d+\b/i,
|
|
18
|
+
/\b(?:issue|pr)\s*#\d+\b.*\back(?:\/|\s*)work(?:\/|\s*)ask(?:\/|\s*)report\b/i,
|
|
19
|
+
/\breport blockers? immediately\b/i,
|
|
20
|
+
];
|
|
21
|
+
const MAINTENANCE_CONTEXT_PATTERNS = [
|
|
22
|
+
/\bralph\b.*\b(?:maintenance|ghost|reap|nudge|drain)\b/i,
|
|
23
|
+
/\bbroker[- ]only maintenance\b/i,
|
|
24
|
+
/\bmaintenance (?:anomaly|recovery|timer|pass)\b/i,
|
|
25
|
+
/\bno further repl(?:y|ies) (?:are|is) needed\b/i,
|
|
26
|
+
/\bno further acknowledg(?:ement|ements) (?:are|is) needed\b/i,
|
|
27
|
+
/\bno reply is needed\b/i,
|
|
28
|
+
/\bno action needed\b/i,
|
|
29
|
+
/\bhard stop on this [^.\n]*thread\b/i,
|
|
30
|
+
/\bstand down\b/i,
|
|
31
|
+
/\bthread is already satisfied\b/i,
|
|
32
|
+
/\bunless I (?:assign|ask for) (?:a )?(?:genuinely )?new task\b/i,
|
|
33
|
+
/\bstay free(?:\/| and )quiet\b/i,
|
|
34
|
+
/\bstay quiet(?:\/| and )free\b/i,
|
|
35
|
+
];
|
|
36
|
+
function asString(value) {
|
|
37
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
38
|
+
}
|
|
39
|
+
export function normalizePinetMailClass(value) {
|
|
40
|
+
const raw = asString(value)
|
|
41
|
+
?.toLowerCase()
|
|
42
|
+
.replace(/[\s-]+/g, "_");
|
|
43
|
+
if (!raw)
|
|
44
|
+
return null;
|
|
45
|
+
if (raw === "steering" || raw === "steer" || raw === "directive")
|
|
46
|
+
return "steering";
|
|
47
|
+
if (raw === "fwup" || raw === "follow_up" || raw === "followup")
|
|
48
|
+
return "fwup";
|
|
49
|
+
if (raw === "maintenance_context" ||
|
|
50
|
+
raw === "maintenance" ||
|
|
51
|
+
raw === "context_only" ||
|
|
52
|
+
raw === "context") {
|
|
53
|
+
return "maintenance_context";
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function getExplicitMailClass(metadata) {
|
|
58
|
+
if (!metadata)
|
|
59
|
+
return null;
|
|
60
|
+
for (const key of EXPLICIT_CLASS_KEYS) {
|
|
61
|
+
const normalized = normalizePinetMailClass(metadata[key]);
|
|
62
|
+
if (normalized)
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function hasPattern(patterns, value) {
|
|
68
|
+
return patterns.some((pattern) => pattern.test(value));
|
|
69
|
+
}
|
|
70
|
+
function metadataLooksLikeMaintenance(metadata) {
|
|
71
|
+
const kind = asString(metadata?.kind)?.toLowerCase() ?? "";
|
|
72
|
+
const type = asString(metadata?.type)?.toLowerCase() ?? "";
|
|
73
|
+
const eventType = asString(metadata?.event_type)?.toLowerCase() ?? "";
|
|
74
|
+
return [kind, type, eventType].some((value) => {
|
|
75
|
+
const normalized = value.replace(/_/g, ":");
|
|
76
|
+
return (normalized.includes("maintenance") ||
|
|
77
|
+
normalized.includes("ralph") ||
|
|
78
|
+
normalized === "pinet:control" ||
|
|
79
|
+
normalized === "pinet:skin");
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export function classifyPinetMail(input) {
|
|
83
|
+
const metadata = input.metadata ?? null;
|
|
84
|
+
const explicitClass = getExplicitMailClass(metadata);
|
|
85
|
+
if (explicitClass) {
|
|
86
|
+
return { class: explicitClass, reason: "explicit metadata", explicit: true };
|
|
87
|
+
}
|
|
88
|
+
const body = input.body ?? "";
|
|
89
|
+
if (metadataLooksLikeMaintenance(metadata) || hasPattern(MAINTENANCE_CONTEXT_PATTERNS, body)) {
|
|
90
|
+
return {
|
|
91
|
+
class: "maintenance_context",
|
|
92
|
+
reason: "maintenance/context-only cues",
|
|
93
|
+
explicit: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (hasPattern(STEERING_PATTERNS, body)) {
|
|
97
|
+
return { class: "steering", reason: "actionable steering cues", explicit: false };
|
|
98
|
+
}
|
|
99
|
+
return { class: "fwup", reason: "default follow-up mail", explicit: false };
|
|
100
|
+
}
|
|
101
|
+
export function formatPinetMailClassLabel(mailClass) {
|
|
102
|
+
return mailClass === "maintenance_context" ? "maintenance/context" : mailClass;
|
|
103
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AgentInfo, BacklogEntry, PortLeaseInfo, ThreadInfo } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_BROKER_MAINTENANCE_INTERVAL_MS = 5000;
|
|
3
|
+
export declare const DEFAULT_BUSY_ASSIGNMENT_AGE_MS = 30000;
|
|
4
|
+
export declare const OVERLOADED_INBOX_THRESHOLD = 10;
|
|
5
|
+
export interface ThreadRepairResult {
|
|
6
|
+
releasedClaimCount: number;
|
|
7
|
+
releasedAgentIds: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface BacklogAssignmentRepairResult {
|
|
10
|
+
resetToPendingCount: number;
|
|
11
|
+
droppedCount: number;
|
|
12
|
+
}
|
|
13
|
+
export interface BrokerMaintenanceDB {
|
|
14
|
+
pruneStaleAgents(staleAfterMs: number): string[];
|
|
15
|
+
purgeDisconnectedAgents(graceMs?: number): string[];
|
|
16
|
+
repairThreadOwnership(): ThreadRepairResult;
|
|
17
|
+
repairOrphanedAssignedBacklog(): BacklogAssignmentRepairResult;
|
|
18
|
+
requeueUndeliveredMessages(agentId: string, reason?: string): number;
|
|
19
|
+
getPendingBacklog(limit?: number): BacklogEntry[];
|
|
20
|
+
getBacklogCount(status?: BacklogEntry["status"]): number;
|
|
21
|
+
getAgentById(agentId: string): AgentInfo | null;
|
|
22
|
+
getAgents(): AgentInfo[];
|
|
23
|
+
getPendingInboxCount(agentId: string): number;
|
|
24
|
+
getThread(threadId: string): ThreadInfo | null;
|
|
25
|
+
assignBacklogEntry(id: number, agentId: string): BacklogEntry | null;
|
|
26
|
+
dropBacklogEntry(id: number, reason: string): BacklogEntry | null;
|
|
27
|
+
expirePortLeases?: (nowIso?: string) => PortLeaseInfo[];
|
|
28
|
+
}
|
|
29
|
+
export interface BrokerMaintenanceOptions {
|
|
30
|
+
brokerAgentId?: string;
|
|
31
|
+
staleAfterMs: number;
|
|
32
|
+
backlogLimit?: number;
|
|
33
|
+
busyAssignmentAgeMs?: number;
|
|
34
|
+
now?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface BrokerMaintenanceResult {
|
|
37
|
+
reapedAgentIds: string[];
|
|
38
|
+
repairedThreadClaims: number;
|
|
39
|
+
assignedBacklogCount: number;
|
|
40
|
+
nudgedAgentIds: string[];
|
|
41
|
+
pendingBacklogCount: number;
|
|
42
|
+
anomalies: string[];
|
|
43
|
+
}
|
|
44
|
+
interface AgentLoad {
|
|
45
|
+
agent: AgentInfo;
|
|
46
|
+
pendingInboxCount: number;
|
|
47
|
+
}
|
|
48
|
+
export declare function selectBacklogAssignee(backlog: BacklogEntry, agentLoads: AgentLoad[], now?: number, busyAssignmentAgeMs?: number): AgentInfo | null;
|
|
49
|
+
export declare function runBrokerMaintenancePass(db: BrokerMaintenanceDB, options: BrokerMaintenanceOptions): BrokerMaintenanceResult;
|
|
50
|
+
export {};
|