@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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export const DEFAULT_BROKER_MAINTENANCE_INTERVAL_MS = 5_000;
|
|
2
|
+
export const DEFAULT_BUSY_ASSIGNMENT_AGE_MS = 30_000;
|
|
3
|
+
export const OVERLOADED_INBOX_THRESHOLD = 10;
|
|
4
|
+
export function selectBacklogAssignee(backlog, agentLoads, now = Date.now(), busyAssignmentAgeMs = DEFAULT_BUSY_ASSIGNMENT_AGE_MS) {
|
|
5
|
+
if (agentLoads.length === 0) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
const idle = agentLoads.filter((entry) => entry.agent.status === "idle").sort(compareAgentLoad);
|
|
9
|
+
if (idle.length > 0) {
|
|
10
|
+
return idle[0].agent;
|
|
11
|
+
}
|
|
12
|
+
const backlogAgeMs = now - Date.parse(backlog.createdAt);
|
|
13
|
+
if (backlogAgeMs < busyAssignmentAgeMs) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const working = [...agentLoads].sort(compareAgentLoad);
|
|
17
|
+
return working[0]?.agent ?? null;
|
|
18
|
+
}
|
|
19
|
+
export function runBrokerMaintenancePass(db, options) {
|
|
20
|
+
const now = options.now ?? Date.now();
|
|
21
|
+
const busyAssignmentAgeMs = options.busyAssignmentAgeMs ?? DEFAULT_BUSY_ASSIGNMENT_AGE_MS;
|
|
22
|
+
const reapedAgentIds = db.pruneStaleAgents(options.staleAfterMs);
|
|
23
|
+
const expiredPortLeases = db.expirePortLeases?.(new Date(now).toISOString()) ?? [];
|
|
24
|
+
db.purgeDisconnectedAgents();
|
|
25
|
+
const repaired = db.repairThreadOwnership();
|
|
26
|
+
for (const agentId of repaired.releasedAgentIds) {
|
|
27
|
+
db.requeueUndeliveredMessages(agentId, "agent_disconnected");
|
|
28
|
+
}
|
|
29
|
+
const repairedAssignments = db.repairOrphanedAssignedBacklog();
|
|
30
|
+
const repairedThreadClaims = repaired.releasedClaimCount;
|
|
31
|
+
const brokerAgentId = options.brokerAgentId;
|
|
32
|
+
const agents = db
|
|
33
|
+
.getAgents()
|
|
34
|
+
.filter((agent) => agent.id !== brokerAgentId)
|
|
35
|
+
.filter((agent) => agent.metadata?.role !== "broker");
|
|
36
|
+
const agentLoads = agents.map((agent) => ({
|
|
37
|
+
agent,
|
|
38
|
+
pendingInboxCount: db.getPendingInboxCount(agent.id),
|
|
39
|
+
}));
|
|
40
|
+
const nudgedAgentIds = new Set();
|
|
41
|
+
let assignedBacklogCount = 0;
|
|
42
|
+
let reboundBrokerBacklogCount = 0;
|
|
43
|
+
const resetAssignedBacklogCount = repairedAssignments.resetToPendingCount;
|
|
44
|
+
let droppedBacklogCount = repairedAssignments.droppedCount;
|
|
45
|
+
for (const backlog of db.getPendingBacklog(options.backlogLimit ?? 50)) {
|
|
46
|
+
if (brokerAgentId && backlog.preferredAgentId === brokerAgentId) {
|
|
47
|
+
const assigned = db.assignBacklogEntry(backlog.id, brokerAgentId);
|
|
48
|
+
if (!assigned) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
assignedBacklogCount += 1;
|
|
52
|
+
reboundBrokerBacklogCount += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const preferredAgent = backlog.preferredAgentId
|
|
56
|
+
? (agents.find((agent) => agent.id === backlog.preferredAgentId) ?? null)
|
|
57
|
+
: null;
|
|
58
|
+
if (backlog.preferredAgentId && !preferredAgent) {
|
|
59
|
+
const knownPreferredAgent = db.getAgentById(backlog.preferredAgentId);
|
|
60
|
+
if (!knownPreferredAgent) {
|
|
61
|
+
const dropped = db.dropBacklogEntry(backlog.id, "preferred_agent_missing");
|
|
62
|
+
if (dropped) {
|
|
63
|
+
droppedBacklogCount += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const threadOwner = db.getThread(backlog.threadId)?.ownerAgent ?? null;
|
|
69
|
+
const ownerAgent = threadOwner ? agents.find((agent) => agent.id === threadOwner) : null;
|
|
70
|
+
const assignee = preferredAgent ??
|
|
71
|
+
ownerAgent ??
|
|
72
|
+
selectBacklogAssignee(backlog, agentLoads, now, busyAssignmentAgeMs);
|
|
73
|
+
if (!assignee) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const assigned = db.assignBacklogEntry(backlog.id, assignee.id);
|
|
77
|
+
if (!assigned) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
assignedBacklogCount += 1;
|
|
81
|
+
nudgedAgentIds.add(assignee.id);
|
|
82
|
+
const load = agentLoads.find((entry) => entry.agent.id === assignee.id);
|
|
83
|
+
if (load) {
|
|
84
|
+
load.pendingInboxCount += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const pendingBacklogCount = db.getBacklogCount("pending");
|
|
88
|
+
const anomalies = [];
|
|
89
|
+
if (reapedAgentIds.length > 0) {
|
|
90
|
+
anomalies.push(`reaped ${reapedAgentIds.length} stale agent${reapedAgentIds.length === 1 ? "" : "s"}`);
|
|
91
|
+
}
|
|
92
|
+
if (expiredPortLeases.length > 0) {
|
|
93
|
+
anomalies.push(`expired ${expiredPortLeases.length} port lease${expiredPortLeases.length === 1 ? "" : "s"}`);
|
|
94
|
+
}
|
|
95
|
+
if (repairedThreadClaims > 0) {
|
|
96
|
+
anomalies.push(`released ${repairedThreadClaims} orphaned thread claim${repairedThreadClaims === 1 ? "" : "s"}`);
|
|
97
|
+
}
|
|
98
|
+
if (reboundBrokerBacklogCount > 0) {
|
|
99
|
+
anomalies.push(`rebound ${reboundBrokerBacklogCount} broker-targeted backlog item${reboundBrokerBacklogCount === 1 ? "" : "s"} to the live broker`);
|
|
100
|
+
}
|
|
101
|
+
if (resetAssignedBacklogCount > 0) {
|
|
102
|
+
anomalies.push(`reset ${resetAssignedBacklogCount} orphaned backlog assignment${resetAssignedBacklogCount === 1 ? "" : "s"} to pending`);
|
|
103
|
+
}
|
|
104
|
+
if (droppedBacklogCount > 0) {
|
|
105
|
+
anomalies.push(`dropped ${droppedBacklogCount} undeliverable targeted backlog entr${droppedBacklogCount === 1 ? "y" : "ies"}`);
|
|
106
|
+
}
|
|
107
|
+
if (pendingBacklogCount > 0 && agentLoads.length === 0) {
|
|
108
|
+
anomalies.push("pending unrouted backlog has no live workers");
|
|
109
|
+
}
|
|
110
|
+
else if (pendingBacklogCount > 0 &&
|
|
111
|
+
!agentLoads.some((entry) => entry.agent.status === "idle")) {
|
|
112
|
+
anomalies.push("pending unrouted backlog is waiting for an idle worker");
|
|
113
|
+
}
|
|
114
|
+
const overloadedAgents = agentLoads
|
|
115
|
+
.filter((entry) => entry.pendingInboxCount >= OVERLOADED_INBOX_THRESHOLD)
|
|
116
|
+
.map((entry) => entry.agent.name);
|
|
117
|
+
if (overloadedAgents.length > 0) {
|
|
118
|
+
anomalies.push(`overloaded workers: ${overloadedAgents.join(", ")}`);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
reapedAgentIds,
|
|
122
|
+
repairedThreadClaims,
|
|
123
|
+
assignedBacklogCount,
|
|
124
|
+
nudgedAgentIds: [...nudgedAgentIds],
|
|
125
|
+
pendingBacklogCount,
|
|
126
|
+
anomalies,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function compareAgentLoad(left, right) {
|
|
130
|
+
if (left.pendingInboxCount !== right.pendingInboxCount) {
|
|
131
|
+
return left.pendingInboxCount - right.pendingInboxCount;
|
|
132
|
+
}
|
|
133
|
+
return Date.parse(left.agent.lastSeen) - Date.parse(right.agent.lastSeen);
|
|
134
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { BrokerMessage, MessageAdapter, NormalizedMessageContent, ThreadInfo } from "./types.js";
|
|
2
|
+
export interface BrokerMessageSenderDb {
|
|
3
|
+
getThread(threadId: string): ThreadInfo | null;
|
|
4
|
+
createThread(threadId: string, source: string, channel: string, ownerAgent: string | null): ThreadInfo;
|
|
5
|
+
updateThread(threadId: string, updates: Partial<ThreadInfo>): void;
|
|
6
|
+
claimThread(threadId: string, agentId: string, source?: string, channel?: string): boolean;
|
|
7
|
+
insertMessage(threadId: string, source: string, direction: "inbound" | "outbound", sender: string, body: string, targetAgentIds: string[], metadata?: Record<string, unknown>): BrokerMessage;
|
|
8
|
+
}
|
|
9
|
+
export interface BrokerMessageSenderDeps {
|
|
10
|
+
db: BrokerMessageSenderDb;
|
|
11
|
+
adapters: ReadonlyArray<Pick<MessageAdapter, "name" | "send">>;
|
|
12
|
+
}
|
|
13
|
+
export interface SendBrokerMessageInput {
|
|
14
|
+
threadId: string;
|
|
15
|
+
body: string;
|
|
16
|
+
senderAgentId: string;
|
|
17
|
+
source?: string;
|
|
18
|
+
channel?: string;
|
|
19
|
+
content?: NormalizedMessageContent;
|
|
20
|
+
blocks?: ReadonlyArray<Record<string, unknown>>;
|
|
21
|
+
agentName?: string;
|
|
22
|
+
agentEmoji?: string;
|
|
23
|
+
agentOwnerToken?: string;
|
|
24
|
+
metadata?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
export interface SendBrokerMessageResult {
|
|
27
|
+
thread: ThreadInfo;
|
|
28
|
+
message: BrokerMessage;
|
|
29
|
+
adapter: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function sendBrokerMessage(deps: BrokerMessageSenderDeps, input: SendBrokerMessageInput): Promise<SendBrokerMessageResult>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
function normalizeMessageContent(content) {
|
|
2
|
+
if (!content) {
|
|
3
|
+
return undefined;
|
|
4
|
+
}
|
|
5
|
+
const text = content.text.trim();
|
|
6
|
+
if (!text) {
|
|
7
|
+
throw new Error("content.text is required when content is provided.");
|
|
8
|
+
}
|
|
9
|
+
const markdown = content.markdown?.trim();
|
|
10
|
+
return {
|
|
11
|
+
text,
|
|
12
|
+
...(markdown ? { markdown } : {}),
|
|
13
|
+
...(content.slackBlocks && content.slackBlocks.length > 0
|
|
14
|
+
? { slackBlocks: content.slackBlocks }
|
|
15
|
+
: {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export async function sendBrokerMessage(deps, input) {
|
|
19
|
+
const threadId = input.threadId.trim();
|
|
20
|
+
const body = input.body.trim();
|
|
21
|
+
if (!threadId || !body) {
|
|
22
|
+
throw new Error("threadId and body are required.");
|
|
23
|
+
}
|
|
24
|
+
const existingThread = deps.db.getThread(threadId);
|
|
25
|
+
const source = (input.source ?? existingThread?.source ?? "").trim();
|
|
26
|
+
const channel = (input.channel ?? existingThread?.channel ?? "").trim();
|
|
27
|
+
if (!source) {
|
|
28
|
+
throw new Error(`No transport source is recorded for thread ${threadId}.`);
|
|
29
|
+
}
|
|
30
|
+
if (!channel) {
|
|
31
|
+
throw new Error(`No transport channel is recorded for thread ${threadId}.`);
|
|
32
|
+
}
|
|
33
|
+
const adapter = deps.adapters.find((candidate) => candidate.name === source);
|
|
34
|
+
if (!adapter) {
|
|
35
|
+
throw new Error(`No adapter is registered for transport source ${JSON.stringify(source)}.`);
|
|
36
|
+
}
|
|
37
|
+
const content = normalizeMessageContent(input.content);
|
|
38
|
+
const messageBody = content?.text ?? body;
|
|
39
|
+
let thread = existingThread;
|
|
40
|
+
if (thread?.ownerAgent && thread.ownerAgent !== input.senderAgentId) {
|
|
41
|
+
throw new Error(`Thread ${threadId} is already owned by another agent.`);
|
|
42
|
+
}
|
|
43
|
+
if (!thread || thread.ownerAgent === null) {
|
|
44
|
+
const claimed = deps.db.claimThread(threadId, input.senderAgentId, source, channel);
|
|
45
|
+
if (!claimed) {
|
|
46
|
+
throw new Error(`Thread ${threadId} is already owned by another agent.`);
|
|
47
|
+
}
|
|
48
|
+
thread = deps.db.getThread(threadId);
|
|
49
|
+
if (!thread) {
|
|
50
|
+
throw new Error(`Thread ${threadId} was claimed but could not be read back.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (thread.source !== source || thread.channel !== channel) {
|
|
54
|
+
deps.db.updateThread(threadId, { source, channel });
|
|
55
|
+
thread = { ...thread, source, channel };
|
|
56
|
+
}
|
|
57
|
+
const outbound = {
|
|
58
|
+
threadId,
|
|
59
|
+
channel,
|
|
60
|
+
text: messageBody,
|
|
61
|
+
...(content ? { content } : {}),
|
|
62
|
+
...(input.blocks && input.blocks.length > 0 ? { blocks: input.blocks } : {}),
|
|
63
|
+
...(input.agentName ? { agentName: input.agentName } : {}),
|
|
64
|
+
...(input.agentEmoji ? { agentEmoji: input.agentEmoji } : {}),
|
|
65
|
+
...(input.agentOwnerToken ? { agentOwnerToken: input.agentOwnerToken } : {}),
|
|
66
|
+
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
67
|
+
};
|
|
68
|
+
await adapter.send(outbound);
|
|
69
|
+
const message = deps.db.insertMessage(threadId, source, "outbound", input.senderAgentId, messageBody, [], input.metadata);
|
|
70
|
+
return {
|
|
71
|
+
thread,
|
|
72
|
+
message,
|
|
73
|
+
adapter: adapter.name,
|
|
74
|
+
};
|
|
75
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized Pinet broker file paths.
|
|
3
|
+
* All socket and database path constants are defined here to ensure consistency
|
|
4
|
+
* across the broker, client, and schema modules.
|
|
5
|
+
*/
|
|
6
|
+
/** Default Pinet config directory: ~/.pi */
|
|
7
|
+
export declare function getPinetConfigDir(): string;
|
|
8
|
+
/** Default Unix socket path for broker communication: ~/.pi/pinet.sock */
|
|
9
|
+
export declare function getDefaultSocketPath(): string;
|
|
10
|
+
export declare const DEFAULT_SOCKET_PATH: string;
|
|
11
|
+
/** Default SQLite database path for broker: ~/.pi/pinet-broker.db */
|
|
12
|
+
export declare function getDefaultDbPath(): string;
|
|
13
|
+
/** Shared secret file used to authenticate local Pinet mesh clients: ~/.pi/pinet.secret */
|
|
14
|
+
export declare function getDefaultMeshSecretPath(): string;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized Pinet broker file paths.
|
|
3
|
+
* All socket and database path constants are defined here to ensure consistency
|
|
4
|
+
* across the broker, client, and schema modules.
|
|
5
|
+
*/
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
// ─── Directories ─────────────────────────────────────────
|
|
9
|
+
/** Default Pinet config directory: ~/.pi */
|
|
10
|
+
export function getPinetConfigDir() {
|
|
11
|
+
return path.join(os.homedir(), ".pi");
|
|
12
|
+
}
|
|
13
|
+
// ─── Socket Paths ────────────────────────────────────────
|
|
14
|
+
/** Default Unix socket path for broker communication: ~/.pi/pinet.sock */
|
|
15
|
+
export function getDefaultSocketPath() {
|
|
16
|
+
return path.join(getPinetConfigDir(), "pinet.sock");
|
|
17
|
+
}
|
|
18
|
+
// Re-export as static constant for backward compatibility
|
|
19
|
+
export const DEFAULT_SOCKET_PATH = getDefaultSocketPath();
|
|
20
|
+
// ─── Database Paths ──────────────────────────────────────
|
|
21
|
+
/** Default SQLite database path for broker: ~/.pi/pinet-broker.db */
|
|
22
|
+
export function getDefaultDbPath() {
|
|
23
|
+
return path.join(getPinetConfigDir(), "pinet-broker.db");
|
|
24
|
+
}
|
|
25
|
+
// ─── Mesh auth paths ─────────────────────────────────────
|
|
26
|
+
/** Shared secret file used to authenticate local Pinet mesh clients: ~/.pi/pinet.secret */
|
|
27
|
+
export function getDefaultMeshSecretPath() {
|
|
28
|
+
return path.join(getPinetConfigDir(), "pinet.secret");
|
|
29
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
function normalizeTcpHost(host) {
|
|
3
|
+
const trimmed = host.trim();
|
|
4
|
+
const unwrapped = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
|
5
|
+
return unwrapped.toLowerCase().replace(/\.$/, "");
|
|
6
|
+
}
|
|
7
|
+
function isLoopbackIpv4Host(host) {
|
|
8
|
+
if (net.isIP(host) !== 4) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const octets = host.split(".");
|
|
12
|
+
if (octets.length !== 4) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return (octets[0] === "127" &&
|
|
16
|
+
octets.every((octet) => {
|
|
17
|
+
if (!/^\d+$/.test(octet)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const value = Number(octet);
|
|
21
|
+
return value >= 0 && value <= 255;
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
export function isLoopbackTcpHost(host) {
|
|
25
|
+
const normalized = normalizeTcpHost(host);
|
|
26
|
+
if (normalized === "localhost" || normalized === "::1") {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (normalized.startsWith("::ffff:")) {
|
|
30
|
+
return isLoopbackTcpHost(normalized.slice("::ffff:".length));
|
|
31
|
+
}
|
|
32
|
+
return isLoopbackIpv4Host(normalized);
|
|
33
|
+
}
|
|
34
|
+
export function assertLoopbackTcpHost(host, targetDescription) {
|
|
35
|
+
if (isLoopbackTcpHost(host)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`Refusing ${targetDescription} on non-loopback raw TCP host "${host}". Raw TCP broker endpoints are limited to loopback-only hosts until a secure remote transport exists.`);
|
|
39
|
+
}
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AgentInfo, BrokerDBInterface, InboundMessage, RoutingDecision } from "./types.js";
|
|
2
|
+
export interface ThreadOwnerHint {
|
|
3
|
+
agentId?: string;
|
|
4
|
+
stableId?: string;
|
|
5
|
+
agentOwner?: string;
|
|
6
|
+
agentName?: string;
|
|
7
|
+
}
|
|
8
|
+
export type ExplicitThreadDirective = {
|
|
9
|
+
kind: "stand_down";
|
|
10
|
+
agent: AgentInfo;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "retarget";
|
|
13
|
+
agent: AgentInfo;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Extract an agent name mention from message text.
|
|
17
|
+
* Matches patterns like "hey AgentName," or "@AgentName" or just "AgentName"
|
|
18
|
+
* at word boundaries (case-insensitive).
|
|
19
|
+
*
|
|
20
|
+
* When multiple agents match, the longest name wins so that "CodeBot" is
|
|
21
|
+
* preferred over "Code" and similar-prefix collisions are avoided.
|
|
22
|
+
*/
|
|
23
|
+
export declare function findAgentMention(text: string, agents: AgentInfo[]): AgentInfo | null;
|
|
24
|
+
export declare function extractPiAgentThreadOwnerHint(replies: ReadonlyArray<Record<string, unknown>>): ThreadOwnerHint | null;
|
|
25
|
+
export declare function findExplicitThreadDirective(text: string, agents: AgentInfo[]): ExplicitThreadDirective | null;
|
|
26
|
+
export declare class MessageRouter {
|
|
27
|
+
private readonly db;
|
|
28
|
+
constructor(db: BrokerDBInterface);
|
|
29
|
+
/**
|
|
30
|
+
* Route an inbound message to the right agent.
|
|
31
|
+
*
|
|
32
|
+
* Priority order:
|
|
33
|
+
* 1. User allowlist — reject if user not allowed
|
|
34
|
+
* 2. Explicit thread control — stand-down / reassignment signals in-thread
|
|
35
|
+
* 3. Known-thread ownership — authoritative owner hint, then broker DB owner
|
|
36
|
+
* 4. New-thread channel assignment / direct address
|
|
37
|
+
* 5. Unrouted — no match found
|
|
38
|
+
*/
|
|
39
|
+
route(msg: InboundMessage): RoutingDecision;
|
|
40
|
+
/**
|
|
41
|
+
* Claim a thread for an agent (first-responder-wins).
|
|
42
|
+
* Optionally provide the transport source and channel to store when creating
|
|
43
|
+
* a new thread. Defaults to a neutral external source when callers do not
|
|
44
|
+
* provide one; Slack call sites should continue passing `source: "slack"`
|
|
45
|
+
* explicitly through inbound messages or compatibility wrappers.
|
|
46
|
+
* Returns true if the claim succeeded, false if another agent already owns it.
|
|
47
|
+
*
|
|
48
|
+
* Delegates to the DB layer which performs the claim atomically
|
|
49
|
+
* (single SQL statement) to avoid TOCTOU races. (#125)
|
|
50
|
+
*/
|
|
51
|
+
claimThread(threadId: string, agentId: string, channel?: string, source?: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Get the owner of a thread, or null if unclaimed / nonexistent.
|
|
54
|
+
*/
|
|
55
|
+
getThreadOwner(threadId: string): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* List available (connected) agents for routing.
|
|
58
|
+
*/
|
|
59
|
+
getAvailableAgents(): AgentInfo[];
|
|
60
|
+
}
|