@agentlinkdev/agentlink 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 +73 -0
- package/bin/cli.js +382 -0
- package/openclaw.plugin.json +83 -0
- package/package.json +62 -0
- package/src/channel.ts +91 -0
- package/src/contacts.ts +69 -0
- package/src/index.ts +269 -0
- package/src/invite.ts +99 -0
- package/src/jobs.ts +156 -0
- package/src/mqtt-client.ts +142 -0
- package/src/mqtt-service.ts +157 -0
- package/src/routing.ts +53 -0
- package/src/state.ts +169 -0
- package/src/tools.ts +352 -0
- package/src/types.ts +258 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import mqtt from "mqtt";
|
|
2
|
+
import type { AgentLinkConfig } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export interface PublishOptions {
|
|
5
|
+
retain?: boolean;
|
|
6
|
+
qos?: 0 | 1 | 2;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface MqttClient {
|
|
10
|
+
connect(): Promise<void>;
|
|
11
|
+
disconnect(): Promise<void>;
|
|
12
|
+
subscribe(topic: string): Promise<void>;
|
|
13
|
+
unsubscribe(topic: string): Promise<void>;
|
|
14
|
+
publish(topic: string, payload: string | Buffer, options?: PublishOptions): Promise<void>;
|
|
15
|
+
onMessage(handler: (topic: string, payload: Buffer) => void): void;
|
|
16
|
+
isConnected(): boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Logger {
|
|
20
|
+
info: (message: string) => void;
|
|
21
|
+
warn: (message: string) => void;
|
|
22
|
+
error: (message: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createMqttClient(config: AgentLinkConfig, logger: Logger): MqttClient {
|
|
26
|
+
let client: mqtt.MqttClient | null = null;
|
|
27
|
+
const messageHandlers: Array<(topic: string, payload: Buffer) => void> = [];
|
|
28
|
+
let subscribedTopics: Set<string> = new Set();
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
async connect() {
|
|
32
|
+
client = mqtt.connect(config.brokerUrl, {
|
|
33
|
+
username: config.brokerUsername,
|
|
34
|
+
password: config.brokerPassword,
|
|
35
|
+
clientId: `agentlink-${config.agent.id}-${Date.now()}`,
|
|
36
|
+
clean: true,
|
|
37
|
+
keepalive: 30,
|
|
38
|
+
reconnectPeriod: 5000,
|
|
39
|
+
connectTimeout: 10_000,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return new Promise<void>((resolve) => {
|
|
43
|
+
let resolved = false;
|
|
44
|
+
|
|
45
|
+
client!.on("connect", () => {
|
|
46
|
+
if (!resolved) {
|
|
47
|
+
resolved = true;
|
|
48
|
+
logger.info(`[AgentLink] Connected to broker: ${config.brokerUrl}`);
|
|
49
|
+
resolve();
|
|
50
|
+
} else {
|
|
51
|
+
logger.info(`[AgentLink] Reconnected to broker`);
|
|
52
|
+
}
|
|
53
|
+
// Resubscribe on every connect (clean:true means broker forgets subscriptions)
|
|
54
|
+
for (const topic of subscribedTopics) {
|
|
55
|
+
client!.subscribe(topic, { qos: 1 });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
client!.on("error", (err: Error) => {
|
|
60
|
+
if (!resolved) {
|
|
61
|
+
// First connection attempt failed — start anyway, mqtt.js will keep retrying
|
|
62
|
+
resolved = true;
|
|
63
|
+
logger.warn(`[AgentLink] Broker unavailable (${err.message}), will retry in background`);
|
|
64
|
+
resolve();
|
|
65
|
+
} else {
|
|
66
|
+
logger.warn(`[AgentLink] MQTT error: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
client!.on("message", (topic, payload) => {
|
|
71
|
+
for (const handler of messageHandlers) {
|
|
72
|
+
handler(topic, payload);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
client!.on("reconnect", () => {
|
|
77
|
+
logger.info("[AgentLink] Reconnecting to broker...");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
client!.on("offline", () => {
|
|
81
|
+
logger.warn("[AgentLink] Broker connection lost");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async disconnect() {
|
|
87
|
+
if (client) {
|
|
88
|
+
subscribedTopics.clear();
|
|
89
|
+
await new Promise<void>((resolve) => client!.end(false, {}, () => resolve()));
|
|
90
|
+
client = null;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async subscribe(topic) {
|
|
95
|
+
if (!client) throw new Error("MQTT client not initialized");
|
|
96
|
+
subscribedTopics.add(topic);
|
|
97
|
+
// If not connected, the topic is tracked and will be subscribed on (re)connect
|
|
98
|
+
if (!client.connected) return;
|
|
99
|
+
return new Promise<void>((resolve, reject) => {
|
|
100
|
+
client!.subscribe(topic, { qos: 1 }, (err) => {
|
|
101
|
+
if (err) reject(err);
|
|
102
|
+
else resolve();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async unsubscribe(topic) {
|
|
108
|
+
if (!client) throw new Error("MQTT not connected");
|
|
109
|
+
subscribedTopics.delete(topic);
|
|
110
|
+
return new Promise<void>((resolve, reject) => {
|
|
111
|
+
client!.unsubscribe(topic, {}, (err) => {
|
|
112
|
+
if (err) reject(err);
|
|
113
|
+
else resolve();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async publish(topic, payload, options = {}) {
|
|
119
|
+
if (!client) throw new Error("MQTT client not initialized");
|
|
120
|
+
if (!client.connected) throw new Error("MQTT broker not connected (retrying in background)");
|
|
121
|
+
return new Promise<void>((resolve, reject) => {
|
|
122
|
+
client!.publish(
|
|
123
|
+
topic,
|
|
124
|
+
payload,
|
|
125
|
+
{ qos: options.qos ?? 1, retain: options.retain ?? false },
|
|
126
|
+
(err) => {
|
|
127
|
+
if (err) reject(err);
|
|
128
|
+
else resolve();
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
onMessage(handler) {
|
|
135
|
+
messageHandlers.push(handler);
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
isConnected() {
|
|
139
|
+
return client?.connected ?? false;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createMqttClient, type MqttClient, type PublishOptions, type Logger } from "./mqtt-client.js";
|
|
2
|
+
import type { AgentLinkConfig, MessageEnvelope } from "./types.js";
|
|
3
|
+
import { TOPICS, isInviteMessage, isMessageEnvelope } from "./types.js";
|
|
4
|
+
import type { StateManager } from "./state.js";
|
|
5
|
+
import type { ContactsManager } from "./contacts.js";
|
|
6
|
+
|
|
7
|
+
export interface MqttService {
|
|
8
|
+
start(): Promise<void>;
|
|
9
|
+
stop(): Promise<void>;
|
|
10
|
+
publish(topic: string, payload: string, options?: PublishOptions): Promise<void>;
|
|
11
|
+
publishEnvelope(topic: string, envelope: MessageEnvelope): Promise<void>;
|
|
12
|
+
subscribeGroup(groupId: string): Promise<void>;
|
|
13
|
+
unsubscribeGroup(groupId: string): Promise<void>;
|
|
14
|
+
getClient(): MqttClient;
|
|
15
|
+
onGroupMessage(handler: (msg: MessageEnvelope) => void): void;
|
|
16
|
+
onInboxMessage(handler: (topic: string, msg: unknown) => void): void;
|
|
17
|
+
onStatusUpdate(handler: (msg: unknown) => void): void;
|
|
18
|
+
onSystemEvent(handler: (msg: unknown) => void): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createMqttService(
|
|
22
|
+
config: AgentLinkConfig,
|
|
23
|
+
state: StateManager,
|
|
24
|
+
contacts: ContactsManager,
|
|
25
|
+
logger: Logger,
|
|
26
|
+
): MqttService {
|
|
27
|
+
const client = createMqttClient(config, logger);
|
|
28
|
+
|
|
29
|
+
const groupMessageHandlers: Array<(msg: MessageEnvelope) => void> = [];
|
|
30
|
+
const inboxHandlers: Array<(topic: string, msg: unknown) => void> = [];
|
|
31
|
+
const statusHandlers: Array<(msg: unknown) => void> = [];
|
|
32
|
+
const systemHandlers: Array<(msg: unknown) => void> = [];
|
|
33
|
+
|
|
34
|
+
function routeMessage(topic: string, payload: Buffer) {
|
|
35
|
+
let parsed: unknown;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(payload.toString());
|
|
38
|
+
} catch {
|
|
39
|
+
logger.warn(`[AgentLink] Non-JSON message on ${topic}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Inbox: direct invites and jobs
|
|
44
|
+
if (topic === TOPICS.inbox(config.agent.id)) {
|
|
45
|
+
for (const handler of inboxHandlers) {
|
|
46
|
+
handler(topic, parsed);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Group messages
|
|
52
|
+
const groupMsgMatch = topic.match(/^agentlink\/([^/]+)\/messages\/.+$/);
|
|
53
|
+
if (groupMsgMatch) {
|
|
54
|
+
if (!isMessageEnvelope(parsed)) {
|
|
55
|
+
logger.warn(`[AgentLink] Malformed envelope on ${topic}`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (parsed.from === config.agent.id) return; // ignore echo
|
|
59
|
+
for (const handler of groupMessageHandlers) {
|
|
60
|
+
handler(parsed);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Status updates
|
|
66
|
+
if (topic.match(/^agentlink\/[^/]+\/status\/.+$/)) {
|
|
67
|
+
for (const handler of statusHandlers) {
|
|
68
|
+
handler(parsed);
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// System events (join/leave/complete)
|
|
74
|
+
if (topic.match(/^agentlink\/[^/]+\/system$/)) {
|
|
75
|
+
for (const handler of systemHandlers) {
|
|
76
|
+
handler(parsed);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
async start() {
|
|
84
|
+
await client.connect();
|
|
85
|
+
|
|
86
|
+
// Always subscribe to personal inbox
|
|
87
|
+
await client.subscribe(TOPICS.inbox(config.agent.id));
|
|
88
|
+
|
|
89
|
+
// Resubscribe to active groups from persisted state
|
|
90
|
+
for (const groupId of state.getActiveGroups()) {
|
|
91
|
+
await client.subscribe(TOPICS.groupAll(groupId));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Route all inbound messages
|
|
95
|
+
client.onMessage(routeMessage);
|
|
96
|
+
|
|
97
|
+
// Check for timed-out jobs from before restart
|
|
98
|
+
const timedOut = state.checkTimeouts(config.jobTimeoutMs);
|
|
99
|
+
if (timedOut.length > 0) {
|
|
100
|
+
logger.info(`[AgentLink] ${timedOut.length} job(s) timed out during downtime`);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async stop() {
|
|
105
|
+
// Publish offline status for all active groups
|
|
106
|
+
for (const groupId of state.getActiveGroups()) {
|
|
107
|
+
const statusTopic = TOPICS.groupStatus(groupId, config.agent.id);
|
|
108
|
+
await client.publish(
|
|
109
|
+
statusTopic,
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
agent_id: config.agent.id,
|
|
112
|
+
status: "offline",
|
|
113
|
+
ts: new Date().toISOString(),
|
|
114
|
+
}),
|
|
115
|
+
{ retain: true },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
await client.disconnect();
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async publish(topic, payload, options) {
|
|
122
|
+
await client.publish(topic, payload, options);
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async publishEnvelope(topic, envelope) {
|
|
126
|
+
await client.publish(topic, JSON.stringify(envelope), { qos: 1 });
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
async subscribeGroup(groupId) {
|
|
130
|
+
await client.subscribe(TOPICS.groupAll(groupId));
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async unsubscribeGroup(groupId) {
|
|
134
|
+
await client.unsubscribe(TOPICS.groupAll(groupId));
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
getClient() {
|
|
138
|
+
return client;
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
onGroupMessage(handler) {
|
|
142
|
+
groupMessageHandlers.push(handler);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
onInboxMessage(handler) {
|
|
146
|
+
inboxHandlers.push(handler);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
onStatusUpdate(handler) {
|
|
150
|
+
statusHandlers.push(handler);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
onSystemEvent(handler) {
|
|
154
|
+
systemHandlers.push(handler);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
package/src/routing.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { MessageEnvelope, AgentStatus, Capability } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface Router {
|
|
4
|
+
resolveTarget(msg: MessageEnvelope, groupParticipants: AgentStatus[]): string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createRouter(): Router {
|
|
8
|
+
return {
|
|
9
|
+
resolveTarget(msg, groupParticipants) {
|
|
10
|
+
// Rule 1: Explicit target — route directly
|
|
11
|
+
if (msg.to !== "group") {
|
|
12
|
+
return [msg.to];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Rule 2: Capability match — filter by capability
|
|
16
|
+
if (msg.payload.capability) {
|
|
17
|
+
const capName = msg.payload.capability;
|
|
18
|
+
return groupParticipants
|
|
19
|
+
.filter((p) => p.agent_id !== msg.from)
|
|
20
|
+
.filter((p) => p.capabilities.some((c) => c.name === capName))
|
|
21
|
+
.map((p) => p.agent_id);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Rule 3: No capability — broadcast to all except sender
|
|
25
|
+
return groupParticipants
|
|
26
|
+
.filter((p) => p.agent_id !== msg.from)
|
|
27
|
+
.map((p) => p.agent_id);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Receiver-side: should this agent process the incoming message?
|
|
34
|
+
*/
|
|
35
|
+
export function shouldProcess(
|
|
36
|
+
msg: MessageEnvelope,
|
|
37
|
+
myAgentId: string,
|
|
38
|
+
myCapabilities: Capability[],
|
|
39
|
+
): boolean {
|
|
40
|
+
// Always process if addressed to us directly
|
|
41
|
+
if (msg.to === myAgentId) return true;
|
|
42
|
+
|
|
43
|
+
// If broadcast with capability filter: only process if we have the capability
|
|
44
|
+
if (msg.to === "group" && msg.payload.capability) {
|
|
45
|
+
return myCapabilities.some((c) => c.name === msg.payload.capability);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Broadcast without capability: process (group coordination)
|
|
49
|
+
if (msg.to === "group") return true;
|
|
50
|
+
|
|
51
|
+
// Addressed to someone else
|
|
52
|
+
return false;
|
|
53
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { GroupState, JobStatus, PendingJob, TimedOutJob } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface StateManager {
|
|
6
|
+
// Identity
|
|
7
|
+
getAgentId(): string | null;
|
|
8
|
+
|
|
9
|
+
// Pending joins (from CLI --join flag)
|
|
10
|
+
getPendingJoins(): string[];
|
|
11
|
+
removePendingJoin(code: string): void;
|
|
12
|
+
|
|
13
|
+
// Groups
|
|
14
|
+
addGroup(group: GroupState): void;
|
|
15
|
+
getGroup(groupId: string): GroupState | null;
|
|
16
|
+
removeGroup(groupId: string): void;
|
|
17
|
+
getActiveGroups(): string[];
|
|
18
|
+
updateGroup(groupId: string, updates: Partial<GroupState>): void;
|
|
19
|
+
incrementIdleTurns(groupId: string): number;
|
|
20
|
+
resetIdleTurns(groupId: string): void;
|
|
21
|
+
|
|
22
|
+
// Jobs
|
|
23
|
+
addJob(job: PendingJob): void;
|
|
24
|
+
getJob(correlationId: string): PendingJob | null;
|
|
25
|
+
completeJob(correlationId: string, status: JobStatus, result?: string): void;
|
|
26
|
+
removeJob(correlationId: string): void;
|
|
27
|
+
hasPendingJob(correlationId: string): boolean;
|
|
28
|
+
getJobsForGroup(groupId: string): PendingJob[];
|
|
29
|
+
checkTimeouts(timeoutMs: number): TimedOutJob[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface StateData {
|
|
33
|
+
agent_id?: string;
|
|
34
|
+
pending_joins?: string[];
|
|
35
|
+
groups: Record<string, GroupState>;
|
|
36
|
+
pending_jobs: Record<string, PendingJob>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createState(dataDir: string): StateManager {
|
|
40
|
+
const filePath = path.join(dataDir, "state.json");
|
|
41
|
+
let data: StateData = {
|
|
42
|
+
groups: {},
|
|
43
|
+
pending_jobs: {},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (fs.existsSync(filePath)) {
|
|
47
|
+
const loaded = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
48
|
+
data = {
|
|
49
|
+
...data,
|
|
50
|
+
...loaded,
|
|
51
|
+
groups: loaded.groups ?? data.groups,
|
|
52
|
+
pending_jobs: loaded.pending_jobs ?? data.pending_jobs,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function save() {
|
|
57
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
58
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
getAgentId() {
|
|
63
|
+
return data.agent_id ?? null;
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
getPendingJoins() {
|
|
67
|
+
return data.pending_joins ? [...data.pending_joins] : [];
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
removePendingJoin(code) {
|
|
71
|
+
if (data.pending_joins) {
|
|
72
|
+
data.pending_joins = data.pending_joins.filter((c) => c !== code);
|
|
73
|
+
save();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
addGroup(group) {
|
|
78
|
+
data.groups[group.group_id] = group;
|
|
79
|
+
save();
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
getGroup(groupId) {
|
|
83
|
+
return data.groups[groupId] ?? null;
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
removeGroup(groupId) {
|
|
87
|
+
delete data.groups[groupId];
|
|
88
|
+
for (const [id, job] of Object.entries(data.pending_jobs)) {
|
|
89
|
+
if (job.group_id === groupId) delete data.pending_jobs[id];
|
|
90
|
+
}
|
|
91
|
+
save();
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
getActiveGroups() {
|
|
95
|
+
return Object.keys(data.groups).filter(
|
|
96
|
+
(id) => data.groups[id].status === "active",
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
updateGroup(groupId, updates) {
|
|
101
|
+
if (data.groups[groupId]) {
|
|
102
|
+
Object.assign(data.groups[groupId], updates);
|
|
103
|
+
save();
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
incrementIdleTurns(groupId) {
|
|
108
|
+
const group = data.groups[groupId];
|
|
109
|
+
if (!group) return 0;
|
|
110
|
+
group.idle_turns++;
|
|
111
|
+
save();
|
|
112
|
+
return group.idle_turns;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
resetIdleTurns(groupId) {
|
|
116
|
+
if (data.groups[groupId]) {
|
|
117
|
+
data.groups[groupId].idle_turns = 0;
|
|
118
|
+
save();
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
addJob(job) {
|
|
123
|
+
data.pending_jobs[job.correlation_id] = job;
|
|
124
|
+
save();
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
getJob(correlationId) {
|
|
128
|
+
return data.pending_jobs[correlationId] ?? null;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
completeJob(correlationId, status) {
|
|
132
|
+
const job = data.pending_jobs[correlationId];
|
|
133
|
+
if (job) {
|
|
134
|
+
job.status = status;
|
|
135
|
+
save();
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
removeJob(correlationId) {
|
|
140
|
+
delete data.pending_jobs[correlationId];
|
|
141
|
+
save();
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
hasPendingJob(correlationId) {
|
|
145
|
+
const job = data.pending_jobs[correlationId];
|
|
146
|
+
return !!job && job.status === "requested";
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
getJobsForGroup(groupId) {
|
|
150
|
+
return Object.values(data.pending_jobs).filter((j) => j.group_id === groupId);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
checkTimeouts(timeoutMs) {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const timedOut: TimedOutJob[] = [];
|
|
156
|
+
for (const job of Object.values(data.pending_jobs)) {
|
|
157
|
+
if (job.status === "requested") {
|
|
158
|
+
const elapsed = now - new Date(job.sent_at).getTime();
|
|
159
|
+
if (elapsed > timeoutMs) {
|
|
160
|
+
job.status = "failed";
|
|
161
|
+
timedOut.push({ ...job, timed_out: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (timedOut.length > 0) save();
|
|
166
|
+
return timedOut;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|