spendos 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/.dockerignore +4 -0
- package/.env.example +30 -0
- package/AGENTS.md +212 -0
- package/BOOTSTRAP.md +55 -0
- package/Dockerfile +52 -0
- package/HEARTBEAT.md +7 -0
- package/IDENTITY.md +23 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SOUL.md +202 -0
- package/SUBMISSION.md +128 -0
- package/TOOLS.md +40 -0
- package/USER.md +17 -0
- package/acp-seller/bin/acp.ts +807 -0
- package/acp-seller/config.json +34 -0
- package/acp-seller/package.json +55 -0
- package/acp-seller/src/commands/agent.ts +328 -0
- package/acp-seller/src/commands/bounty.ts +1189 -0
- package/acp-seller/src/commands/deploy.ts +414 -0
- package/acp-seller/src/commands/job.ts +217 -0
- package/acp-seller/src/commands/profile.ts +71 -0
- package/acp-seller/src/commands/resource.ts +91 -0
- package/acp-seller/src/commands/search.ts +327 -0
- package/acp-seller/src/commands/sell.ts +883 -0
- package/acp-seller/src/commands/serve.ts +258 -0
- package/acp-seller/src/commands/setup.ts +399 -0
- package/acp-seller/src/commands/token.ts +88 -0
- package/acp-seller/src/commands/wallet.ts +123 -0
- package/acp-seller/src/lib/api.ts +118 -0
- package/acp-seller/src/lib/auth.ts +291 -0
- package/acp-seller/src/lib/bounty.ts +257 -0
- package/acp-seller/src/lib/client.ts +42 -0
- package/acp-seller/src/lib/config.ts +240 -0
- package/acp-seller/src/lib/open.ts +41 -0
- package/acp-seller/src/lib/openclawCron.ts +138 -0
- package/acp-seller/src/lib/output.ts +104 -0
- package/acp-seller/src/lib/wallet.ts +81 -0
- package/acp-seller/src/seller/offerings/_shared/preTransactionScan.ts +127 -0
- package/acp-seller/src/seller/offerings/canonical-catalog.ts +221 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/handlers.ts +20 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/offering.json +18 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_translate/handlers.ts +21 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_translate/offering.json +22 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/handlers.ts +20 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/offering.json +18 -0
- package/acp-seller/src/seller/runtime/acpSocket.ts +413 -0
- package/acp-seller/src/seller/runtime/logger.ts +36 -0
- package/acp-seller/src/seller/runtime/offeringTypes.ts +52 -0
- package/acp-seller/src/seller/runtime/offerings.ts +277 -0
- package/acp-seller/src/seller/runtime/paymentVerification.test.ts +207 -0
- package/acp-seller/src/seller/runtime/paymentVerification.ts +363 -0
- package/acp-seller/src/seller/runtime/seller.onchain.test.ts +220 -0
- package/acp-seller/src/seller/runtime/seller.test.ts +823 -0
- package/acp-seller/src/seller/runtime/seller.ts +1041 -0
- package/acp-seller/src/seller/runtime/sellerApi.ts +71 -0
- package/acp-seller/src/seller/runtime/startup.ts +270 -0
- package/acp-seller/src/seller/runtime/types.ts +62 -0
- package/acp-seller/tsconfig.json +20 -0
- package/bin/spendos.js +23 -0
- package/contracts/SpendOSAudit.sol +29 -0
- package/dist/mcp-server.mjs +153 -0
- package/jobs/translate.json +7 -0
- package/jobs/tweet-gen.json +7 -0
- package/openclaw.json +41 -0
- package/package.json +49 -0
- package/plugins/spendos-events/index.ts +78 -0
- package/plugins/spendos-events/package.json +14 -0
- package/policies/enforce-bounds.mjs +71 -0
- package/public/index.html +509 -0
- package/public/landing.html +241 -0
- package/railway.json +12 -0
- package/railway.toml +12 -0
- package/scripts/deploy.ts +48 -0
- package/scripts/test-x402-mainnet.ts +30 -0
- package/scripts/xmtp-listener.ts +61 -0
- package/setup.sh +278 -0
- package/skills/spendos/skill.md +26 -0
- package/src/agent.ts +152 -0
- package/src/audit.ts +166 -0
- package/src/governance.ts +367 -0
- package/src/job-registry.ts +306 -0
- package/src/mcp-public.ts +145 -0
- package/src/mcp-server.ts +171 -0
- package/src/opportunity-scanner.ts +138 -0
- package/src/server.ts +870 -0
- package/src/venice-x402.ts +234 -0
- package/src/xmtp.ts +109 -0
- package/src/zerion.ts +58 -0
- package/start.sh +168 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spendos_summarize_url",
|
|
3
|
+
"description": "AI-powered URL summarization. Send any URL, get a concise summary. Powered by Venice AI with SIWE wallet auth on Base mainnet.",
|
|
4
|
+
"jobFee": 0.01,
|
|
5
|
+
"jobFeeType": "fixed",
|
|
6
|
+
"requiredFunds": false,
|
|
7
|
+
"requirement": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"url": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "The URL to summarize"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"required": ["url"]
|
|
16
|
+
},
|
|
17
|
+
"deliverable": "Concise AI summary of the URL content"
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const SPENDOS_URL = process.env.SPENDOS_URL ?? "https://spendos.xyz";
|
|
2
|
+
|
|
3
|
+
export async function executeJob(context: any) {
|
|
4
|
+
const { text, language } = context.requirement ?? context;
|
|
5
|
+
if (!text || !language)
|
|
6
|
+
return { deliverable: 'Error: missing "text" or "language" parameter' };
|
|
7
|
+
|
|
8
|
+
const res = await fetch(`${SPENDOS_URL}/api/internal/jobs/translate`, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "Content-Type": "application/json" },
|
|
11
|
+
body: JSON.stringify({ text, language }),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
const text = await res.text();
|
|
16
|
+
return { deliverable: `Error: ${res.status} ${text.slice(0, 200)}` };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const data = (await res.json()) as any;
|
|
20
|
+
return { deliverable: data.result ?? JSON.stringify(data) };
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spendos_translate",
|
|
3
|
+
"description": "Translate text to any language using AI. Fast, accurate, cheap.",
|
|
4
|
+
"jobFee": 0.02,
|
|
5
|
+
"jobFeeType": "fixed",
|
|
6
|
+
"requiredFunds": false,
|
|
7
|
+
"requirement": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"text": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "Text to translate"
|
|
13
|
+
},
|
|
14
|
+
"language": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "Target language (e.g. Spanish, French, Japanese)"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"required": ["text", "language"]
|
|
20
|
+
},
|
|
21
|
+
"deliverable": "Translated text in the target language"
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const SPENDOS_URL = process.env.SPENDOS_URL ?? "https://spendos.xyz";
|
|
2
|
+
|
|
3
|
+
export async function executeJob(context: any) {
|
|
4
|
+
const { topic } = context.requirement ?? context;
|
|
5
|
+
if (!topic) return { deliverable: 'Error: missing "topic" parameter' };
|
|
6
|
+
|
|
7
|
+
const res = await fetch(`${SPENDOS_URL}/api/internal/jobs/tweet-gen`, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: { "Content-Type": "application/json" },
|
|
10
|
+
body: JSON.stringify({ topic }),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
const text = await res.text();
|
|
15
|
+
return { deliverable: `Error: ${res.status} ${text.slice(0, 200)}` };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const data = (await res.json()) as any;
|
|
19
|
+
return { deliverable: data.result ?? JSON.stringify(data) };
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spendos_tweet_gen",
|
|
3
|
+
"description": "Generate viral crypto tweets from a topic. Returns a punchy 2-3 tweet thread ready to post.",
|
|
4
|
+
"jobFee": 0.01,
|
|
5
|
+
"jobFeeType": "fixed",
|
|
6
|
+
"requiredFunds": false,
|
|
7
|
+
"requirement": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"topic": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "The topic to write tweets about"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"required": ["topic"]
|
|
16
|
+
},
|
|
17
|
+
"deliverable": "Thread of 2-3 crypto tweets"
|
|
18
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Socket.io client that connects to the ACP backend and dispatches events.
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
import { io, type Socket } from "socket.io-client";
|
|
6
|
+
import { SocketEvent, type AcpJobEventData } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const PAGERDUTY_EVENTS_API_URL = "https://events.pagerduty.com/v2/enqueue";
|
|
9
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
|
|
10
|
+
const DEFAULT_DISCONNECT_ALERT_THRESHOLD_MS = 2 * 60 * 1000;
|
|
11
|
+
const DEFAULT_DISCONNECT_MONITOR_INTERVAL_MS = 30 * 1000;
|
|
12
|
+
const DEFAULT_MANUAL_RECONNECT_INTERVAL_MS = 5 * 1000;
|
|
13
|
+
const DEFAULT_FAILED_RECONNECTS_BEFORE_ALERT = 3;
|
|
14
|
+
|
|
15
|
+
type PagerDutyAction = "trigger" | "resolve";
|
|
16
|
+
|
|
17
|
+
type SocketConnectOptions = {
|
|
18
|
+
auth: { walletAddress: string };
|
|
19
|
+
transports: ["websocket"];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface SocketLike {
|
|
23
|
+
connected: boolean;
|
|
24
|
+
on(event: string, handler: (...args: any[]) => void): unknown;
|
|
25
|
+
connect(): void;
|
|
26
|
+
disconnect(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AcpSocketDeps {
|
|
30
|
+
createSocket?: (acpUrl: string, options: SocketConnectOptions) => SocketLike;
|
|
31
|
+
fetchFn?: typeof fetch;
|
|
32
|
+
setIntervalFn?: typeof setInterval;
|
|
33
|
+
clearIntervalFn?: typeof clearInterval;
|
|
34
|
+
nowFn?: () => number;
|
|
35
|
+
heartbeatIntervalMs?: number;
|
|
36
|
+
disconnectAlertThresholdMs?: number;
|
|
37
|
+
disconnectMonitorIntervalMs?: number;
|
|
38
|
+
manualReconnectIntervalMs?: number;
|
|
39
|
+
failedReconnectsBeforeAlert?: number;
|
|
40
|
+
pagerDutyRetryDelayMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PagerDutyOptions {
|
|
44
|
+
routingKey: string;
|
|
45
|
+
dedupKey: string;
|
|
46
|
+
source: string;
|
|
47
|
+
action: PagerDutyAction;
|
|
48
|
+
summary: string;
|
|
49
|
+
severity?: "critical" | "error" | "warning" | "info";
|
|
50
|
+
details?: Record<string, unknown>;
|
|
51
|
+
fetchFn: typeof fetch;
|
|
52
|
+
retryDelayMs?: number;
|
|
53
|
+
shouldRetry?: () => boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PAGERDUTY_RETRY_DELAY_MS = 2_000;
|
|
57
|
+
|
|
58
|
+
async function sendPagerDutyEvent(opts: PagerDutyOptions): Promise<void> {
|
|
59
|
+
const {
|
|
60
|
+
routingKey,
|
|
61
|
+
dedupKey,
|
|
62
|
+
source,
|
|
63
|
+
action,
|
|
64
|
+
summary,
|
|
65
|
+
severity = "critical",
|
|
66
|
+
details,
|
|
67
|
+
fetchFn,
|
|
68
|
+
retryDelayMs = PAGERDUTY_RETRY_DELAY_MS,
|
|
69
|
+
shouldRetry,
|
|
70
|
+
} = opts;
|
|
71
|
+
|
|
72
|
+
if (!routingKey) {
|
|
73
|
+
console.warn(
|
|
74
|
+
"[socket] PAGERDUTY_ROUTING_KEY not set; skipping PagerDuty event",
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const payload = {
|
|
80
|
+
routing_key: routingKey,
|
|
81
|
+
event_action: action,
|
|
82
|
+
dedup_key: dedupKey,
|
|
83
|
+
payload: {
|
|
84
|
+
summary,
|
|
85
|
+
source,
|
|
86
|
+
severity,
|
|
87
|
+
component: "acp-socket",
|
|
88
|
+
group: "seller-runtime",
|
|
89
|
+
class: "socket-connectivity",
|
|
90
|
+
custom_details: details ?? {},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const attempt = async (): Promise<{ ok: boolean; retryable: boolean }> => {
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetchFn(PAGERDUTY_EVENTS_API_URL, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify(payload),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (response.ok) {
|
|
103
|
+
console.log(`[socket] PagerDuty ${action} sent`);
|
|
104
|
+
return { ok: true, retryable: false };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const body = await response.text().catch(() => "<no body>");
|
|
108
|
+
console.error(
|
|
109
|
+
`[socket] PagerDuty ${action} failed: ${response.status} ${body}`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Only retry on server errors (5xx); 4xx are client errors and won't benefit from retry
|
|
113
|
+
return { ok: false, retryable: response.status >= 500 };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
console.error(`[socket] PagerDuty ${action} error: ${message}`);
|
|
117
|
+
// Network failures are retryable
|
|
118
|
+
return { ok: false, retryable: true };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const first = await attempt();
|
|
123
|
+
if (first.ok || !first.retryable) return;
|
|
124
|
+
|
|
125
|
+
if (shouldRetry && !shouldRetry()) {
|
|
126
|
+
console.log(`[socket] PagerDuty ${action} retry skipped (state changed)`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(
|
|
131
|
+
`[socket] PagerDuty ${action} — retrying in ${retryDelayMs}ms...`,
|
|
132
|
+
);
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
134
|
+
|
|
135
|
+
if (shouldRetry && !shouldRetry()) {
|
|
136
|
+
console.log(`[socket] PagerDuty ${action} retry aborted (state changed)`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await attempt();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface AcpSocketCallbacks {
|
|
144
|
+
onNewTask: (data: AcpJobEventData) => void;
|
|
145
|
+
onEvaluate?: (data: AcpJobEventData) => void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface AcpSocketOptions {
|
|
149
|
+
acpUrl: string;
|
|
150
|
+
walletAddress: string;
|
|
151
|
+
callbacks: AcpSocketCallbacks;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Connect to the ACP socket and start listening for seller events.
|
|
156
|
+
* Returns a cleanup function that disconnects the socket.
|
|
157
|
+
*/
|
|
158
|
+
export function connectAcpSocket(
|
|
159
|
+
opts: AcpSocketOptions,
|
|
160
|
+
deps: AcpSocketDeps = {},
|
|
161
|
+
): () => void {
|
|
162
|
+
const { acpUrl, walletAddress, callbacks } = opts;
|
|
163
|
+
|
|
164
|
+
const createSocket =
|
|
165
|
+
deps.createSocket ??
|
|
166
|
+
((url: string, options: SocketConnectOptions): SocketLike =>
|
|
167
|
+
io(url, options) as Socket);
|
|
168
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
169
|
+
const setIntervalFn = deps.setIntervalFn ?? setInterval;
|
|
170
|
+
const clearIntervalFn = deps.clearIntervalFn ?? clearInterval;
|
|
171
|
+
const now = deps.nowFn ?? Date.now;
|
|
172
|
+
|
|
173
|
+
const heartbeatIntervalMs =
|
|
174
|
+
deps.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
175
|
+
const disconnectAlertThresholdMs =
|
|
176
|
+
deps.disconnectAlertThresholdMs ?? DEFAULT_DISCONNECT_ALERT_THRESHOLD_MS;
|
|
177
|
+
const disconnectMonitorIntervalMs =
|
|
178
|
+
deps.disconnectMonitorIntervalMs ?? DEFAULT_DISCONNECT_MONITOR_INTERVAL_MS;
|
|
179
|
+
const manualReconnectIntervalMs =
|
|
180
|
+
deps.manualReconnectIntervalMs ?? DEFAULT_MANUAL_RECONNECT_INTERVAL_MS;
|
|
181
|
+
const failedReconnectsBeforeAlert =
|
|
182
|
+
deps.failedReconnectsBeforeAlert ?? DEFAULT_FAILED_RECONNECTS_BEFORE_ALERT;
|
|
183
|
+
const pagerDutyRetryDelayMs =
|
|
184
|
+
deps.pagerDutyRetryDelayMs ?? PAGERDUTY_RETRY_DELAY_MS;
|
|
185
|
+
|
|
186
|
+
const pdRoutingKey = process.env.PAGERDUTY_ROUTING_KEY ?? "";
|
|
187
|
+
const pdDedupKey = `acp-socket-${walletAddress.toLowerCase()}`;
|
|
188
|
+
const pdSource = `openclaw-acp-seller:${walletAddress}`;
|
|
189
|
+
|
|
190
|
+
let disconnectedAt: number | null = null;
|
|
191
|
+
let failedReconnectAttempts = 0;
|
|
192
|
+
let pdIncidentOpen = false;
|
|
193
|
+
let reconnectInterval: ReturnType<typeof setInterval> | null = null;
|
|
194
|
+
|
|
195
|
+
const socket = createSocket(acpUrl, {
|
|
196
|
+
auth: { walletAddress },
|
|
197
|
+
transports: ["websocket"],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const triggerPagerDuty = (
|
|
201
|
+
summary: string,
|
|
202
|
+
details: Record<string, unknown>,
|
|
203
|
+
): void => {
|
|
204
|
+
if (pdIncidentOpen) return;
|
|
205
|
+
pdIncidentOpen = true;
|
|
206
|
+
void sendPagerDutyEvent({
|
|
207
|
+
routingKey: pdRoutingKey,
|
|
208
|
+
dedupKey: pdDedupKey,
|
|
209
|
+
source: pdSource,
|
|
210
|
+
action: "trigger",
|
|
211
|
+
summary,
|
|
212
|
+
severity: "critical",
|
|
213
|
+
details,
|
|
214
|
+
fetchFn,
|
|
215
|
+
retryDelayMs: pagerDutyRetryDelayMs,
|
|
216
|
+
shouldRetry: () => pdIncidentOpen,
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const resolvePagerDuty = (
|
|
221
|
+
summary: string,
|
|
222
|
+
details: Record<string, unknown>,
|
|
223
|
+
): void => {
|
|
224
|
+
if (!pdIncidentOpen) return;
|
|
225
|
+
pdIncidentOpen = false;
|
|
226
|
+
void sendPagerDutyEvent({
|
|
227
|
+
routingKey: pdRoutingKey,
|
|
228
|
+
dedupKey: pdDedupKey,
|
|
229
|
+
source: pdSource,
|
|
230
|
+
action: "resolve",
|
|
231
|
+
summary,
|
|
232
|
+
severity: "info",
|
|
233
|
+
details,
|
|
234
|
+
fetchFn,
|
|
235
|
+
retryDelayMs: pagerDutyRetryDelayMs,
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const stopReconnectLoop = (): void => {
|
|
240
|
+
if (reconnectInterval) {
|
|
241
|
+
clearIntervalFn(reconnectInterval);
|
|
242
|
+
reconnectInterval = null;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const startReconnectLoop = (): void => {
|
|
247
|
+
if (reconnectInterval) return;
|
|
248
|
+
|
|
249
|
+
console.log(
|
|
250
|
+
`[socket] Server-initiated disconnect — starting manual reconnect loop (${manualReconnectIntervalMs}ms)`,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
reconnectInterval = setIntervalFn(() => {
|
|
254
|
+
if (socket.connected) {
|
|
255
|
+
stopReconnectLoop();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
failedReconnectAttempts += 1;
|
|
260
|
+
console.log(
|
|
261
|
+
`[socket] Manual reconnect attempt #${failedReconnectAttempts}`,
|
|
262
|
+
);
|
|
263
|
+
socket.connect();
|
|
264
|
+
|
|
265
|
+
if (failedReconnectAttempts >= failedReconnectsBeforeAlert) {
|
|
266
|
+
triggerPagerDuty(
|
|
267
|
+
`ACP socket failed to reconnect after ${failedReconnectsBeforeAlert} attempts`,
|
|
268
|
+
{
|
|
269
|
+
walletAddress,
|
|
270
|
+
failedReconnectAttempts,
|
|
271
|
+
acpUrl,
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}, manualReconnectIntervalMs);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
socket.on(
|
|
279
|
+
SocketEvent.ROOM_JOINED,
|
|
280
|
+
(_data: unknown, callback?: (ack: boolean) => void) => {
|
|
281
|
+
console.log("[socket] Joined ACP room");
|
|
282
|
+
if (typeof callback === "function") callback(true);
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
socket.on(
|
|
287
|
+
SocketEvent.ON_NEW_TASK,
|
|
288
|
+
(data: AcpJobEventData, callback?: (ack: boolean) => void) => {
|
|
289
|
+
if (typeof callback === "function") callback(true);
|
|
290
|
+
console.log(`[socket] onNewTask jobId=${data.id} phase=${data.phase}`);
|
|
291
|
+
try {
|
|
292
|
+
callbacks.onNewTask(data);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error(
|
|
295
|
+
`[socket] CRITICAL: onNewTask callback threw synchronously for job ${data.id}:`,
|
|
296
|
+
err instanceof Error ? err.message : String(err),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
socket.on(
|
|
303
|
+
SocketEvent.ON_EVALUATE,
|
|
304
|
+
(data: AcpJobEventData, callback?: (ack: boolean) => void) => {
|
|
305
|
+
if (typeof callback === "function") callback(true);
|
|
306
|
+
console.log(`[socket] onEvaluate jobId=${data.id} phase=${data.phase}`);
|
|
307
|
+
try {
|
|
308
|
+
if (callbacks.onEvaluate) {
|
|
309
|
+
callbacks.onEvaluate(data);
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error(
|
|
313
|
+
`[socket] CRITICAL: onEvaluate callback threw synchronously for job ${data.id}:`,
|
|
314
|
+
err instanceof Error ? err.message : String(err),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
socket.on("connect", () => {
|
|
321
|
+
const wasDisconnected = disconnectedAt !== null;
|
|
322
|
+
let disconnectedMs = 0;
|
|
323
|
+
|
|
324
|
+
if (disconnectedAt !== null) {
|
|
325
|
+
disconnectedMs = now() - disconnectedAt;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
disconnectedAt = null;
|
|
329
|
+
failedReconnectAttempts = 0;
|
|
330
|
+
stopReconnectLoop();
|
|
331
|
+
|
|
332
|
+
console.log("[socket] Connected to ACP");
|
|
333
|
+
|
|
334
|
+
if (wasDisconnected) {
|
|
335
|
+
resolvePagerDuty("ACP socket reconnected successfully", {
|
|
336
|
+
walletAddress,
|
|
337
|
+
disconnectedMs,
|
|
338
|
+
acpUrl,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
socket.on("disconnect", (reason) => {
|
|
344
|
+
console.log(`[socket] Disconnected: ${reason}`);
|
|
345
|
+
if (disconnectedAt === null) {
|
|
346
|
+
disconnectedAt = now();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// "io server disconnect" = server forcibly closed the connection.
|
|
350
|
+
// Socket.io will NOT auto-reconnect — we must explicitly reconnect.
|
|
351
|
+
if (reason === "io server disconnect") {
|
|
352
|
+
startReconnectLoop();
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
socket.on("connect_error", (err: Error) => {
|
|
357
|
+
console.error(`[socket] Connection error: ${err.message}`);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const heartbeatInterval = setIntervalFn(() => {
|
|
361
|
+
if (socket.connected) {
|
|
362
|
+
console.log("[socket] Heartbeat: connected to ACP");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const downForMs = disconnectedAt !== null ? now() - disconnectedAt : 0;
|
|
367
|
+
console.log(
|
|
368
|
+
`[socket] Heartbeat: disconnected for ${Math.floor(downForMs / 1000)}s`,
|
|
369
|
+
);
|
|
370
|
+
}, heartbeatIntervalMs);
|
|
371
|
+
|
|
372
|
+
const disconnectMonitor = setIntervalFn(() => {
|
|
373
|
+
if (disconnectedAt === null) return;
|
|
374
|
+
|
|
375
|
+
const downForMs = now() - disconnectedAt;
|
|
376
|
+
if (downForMs > disconnectAlertThresholdMs) {
|
|
377
|
+
triggerPagerDuty("ACP socket disconnected for over 2 minutes", {
|
|
378
|
+
walletAddress,
|
|
379
|
+
disconnectedForSeconds: Math.floor(downForMs / 1000),
|
|
380
|
+
acpUrl,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}, disconnectMonitorIntervalMs);
|
|
384
|
+
|
|
385
|
+
let isDisconnected = false;
|
|
386
|
+
|
|
387
|
+
const disconnect = () => {
|
|
388
|
+
if (isDisconnected) return;
|
|
389
|
+
isDisconnected = true;
|
|
390
|
+
|
|
391
|
+
stopReconnectLoop();
|
|
392
|
+
clearIntervalFn(heartbeatInterval);
|
|
393
|
+
clearIntervalFn(disconnectMonitor);
|
|
394
|
+
socket.disconnect();
|
|
395
|
+
process.off("SIGINT", onSigInt);
|
|
396
|
+
process.off("SIGTERM", onSigTerm);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const onSigInt = () => {
|
|
400
|
+
disconnect();
|
|
401
|
+
process.exit(0);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const onSigTerm = () => {
|
|
405
|
+
disconnect();
|
|
406
|
+
process.exit(0);
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
process.on("SIGINT", onSigInt);
|
|
410
|
+
process.on("SIGTERM", onSigTerm);
|
|
411
|
+
|
|
412
|
+
return disconnect;
|
|
413
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface Logger {
|
|
2
|
+
debug: (message: string, meta?: Record<string, unknown>) => void;
|
|
3
|
+
info: (message: string, meta?: Record<string, unknown>) => void;
|
|
4
|
+
warn: (message: string, meta?: Record<string, unknown>) => void;
|
|
5
|
+
error: (message: string, meta?: Record<string, unknown>) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function formatMessage(
|
|
9
|
+
level: "debug" | "info" | "warn" | "error",
|
|
10
|
+
scope: string,
|
|
11
|
+
message: string,
|
|
12
|
+
meta?: Record<string, unknown>,
|
|
13
|
+
): string {
|
|
14
|
+
const prefix = `[${scope}]`;
|
|
15
|
+
if (!meta || Object.keys(meta).length === 0) {
|
|
16
|
+
return `${prefix} ${message}`;
|
|
17
|
+
}
|
|
18
|
+
return `${prefix} ${message} ${JSON.stringify({ level, ...meta })}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createLogger(scope: string): Logger {
|
|
22
|
+
return {
|
|
23
|
+
debug: (message, meta) => {
|
|
24
|
+
console.debug(formatMessage("debug", scope, message, meta));
|
|
25
|
+
},
|
|
26
|
+
info: (message, meta) => {
|
|
27
|
+
console.info(formatMessage("info", scope, message, meta));
|
|
28
|
+
},
|
|
29
|
+
warn: (message, meta) => {
|
|
30
|
+
console.warn(formatMessage("warn", scope, message, meta));
|
|
31
|
+
},
|
|
32
|
+
error: (message, meta) => {
|
|
33
|
+
console.error(formatMessage("error", scope, message, meta));
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Shared types for offering handler contracts.
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
/** Optional token-transfer instruction returned by an offering handler. */
|
|
6
|
+
export interface TransferInstruction {
|
|
7
|
+
/** Token contract address (e.g. ERC-20 CA). */
|
|
8
|
+
ca: string;
|
|
9
|
+
/** Amount to transfer. */
|
|
10
|
+
amount: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Result returned by an offering's `executeJob` handler.
|
|
15
|
+
*
|
|
16
|
+
* - `deliverable` — the job result (simple string or structured object).
|
|
17
|
+
* - `payableDetail` — optional: instructs the runtime to include a token transfer
|
|
18
|
+
* in the deliver step (e.g. "return money to buyer").
|
|
19
|
+
*/
|
|
20
|
+
export interface ExecuteJobResult {
|
|
21
|
+
deliverable: string | { type: string; value: unknown };
|
|
22
|
+
payableDetail?: { amount: number; tokenAddress: string };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validation result returned by validateRequirements handler.
|
|
27
|
+
* Can be a simple boolean (backwards compatible) or an object with valid flag and optional reason.
|
|
28
|
+
*/
|
|
29
|
+
export type ValidationResult = boolean | { valid: boolean; reason?: string };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The handler set every offering must / can export.
|
|
33
|
+
*
|
|
34
|
+
* Required:
|
|
35
|
+
* executeJob(request) => ExecuteJobResult
|
|
36
|
+
*
|
|
37
|
+
* Optional:
|
|
38
|
+
* validateRequirements(request) => boolean | { valid: boolean, reason?: string }
|
|
39
|
+
* requestPayment(request) => string
|
|
40
|
+
* requestAdditionalFunds(request) => { content, amount, tokenAddress, recipient }
|
|
41
|
+
*/
|
|
42
|
+
export interface OfferingHandlers {
|
|
43
|
+
executeJob: (request: Record<string, any>) => Promise<ExecuteJobResult>;
|
|
44
|
+
validateRequirements?: (request: Record<string, any>) => ValidationResult;
|
|
45
|
+
requestPayment?: (request: Record<string, any>) => string;
|
|
46
|
+
requestAdditionalFunds?: (request: Record<string, any>) => {
|
|
47
|
+
content?: string;
|
|
48
|
+
amount: number;
|
|
49
|
+
tokenAddress: string;
|
|
50
|
+
recipient: string;
|
|
51
|
+
};
|
|
52
|
+
}
|