@loggie-ai/openclaw-plugin 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/README.md +205 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +12 -0
- package/dist/setup-entry.d.ts +73 -0
- package/dist/setup-entry.js +3 -0
- package/dist/src/account.d.ts +70 -0
- package/dist/src/account.js +182 -0
- package/dist/src/channel.d.ts +72 -0
- package/dist/src/channel.js +105 -0
- package/dist/src/config-schema.d.ts +98 -0
- package/dist/src/config-schema.js +55 -0
- package/dist/src/cursor-store.d.ts +86 -0
- package/dist/src/cursor-store.js +141 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +29 -0
- package/dist/src/event-types.d.ts +113 -0
- package/dist/src/event-types.js +86 -0
- package/dist/src/loggie-client.d.ts +33 -0
- package/dist/src/loggie-client.js +74 -0
- package/dist/src/monitor/connection.d.ts +30 -0
- package/dist/src/monitor/connection.js +289 -0
- package/dist/src/monitor/event-handler.d.ts +27 -0
- package/dist/src/monitor/event-handler.js +90 -0
- package/dist/src/monitor/transcript-dispatch.d.ts +16 -0
- package/dist/src/monitor/transcript-dispatch.js +124 -0
- package/dist/src/monitor/transcript-format.d.ts +2 -0
- package/dist/src/monitor/transcript-format.js +41 -0
- package/dist/src/object.d.ts +4 -0
- package/dist/src/object.js +12 -0
- package/dist/src/routing.d.ts +11 -0
- package/dist/src/routing.js +13 -0
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +6 -0
- package/dist/src/status.d.ts +45 -0
- package/dist/src/status.js +45 -0
- package/index.ts +13 -0
- package/openclaw.plugin.json +71 -0
- package/package.json +93 -0
- package/plugin-inspector.config.json +15 -0
- package/setup-entry.ts +4 -0
- package/src/account.ts +265 -0
- package/src/channel.ts +148 -0
- package/src/config-schema.ts +57 -0
- package/src/cursor-store.ts +233 -0
- package/src/doctor.ts +39 -0
- package/src/event-types.ts +105 -0
- package/src/loggie-client.ts +111 -0
- package/src/monitor/connection.ts +349 -0
- package/src/monitor/event-handler.ts +133 -0
- package/src/monitor/transcript-dispatch.ts +145 -0
- package/src/monitor/transcript-format.ts +49 -0
- package/src/object.ts +15 -0
- package/src/routing.ts +27 -0
- package/src/runtime.ts +13 -0
- package/src/status.ts +72 -0
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core";
|
|
2
|
+
import { listLoggieAccountIds, resolveLoggieAccount } from "./account.js";
|
|
3
|
+
|
|
4
|
+
export function collectLoggieDoctorWarnings(params: {
|
|
5
|
+
cfg: OpenClawConfig;
|
|
6
|
+
env?: NodeJS.ProcessEnv;
|
|
7
|
+
}): string[] {
|
|
8
|
+
const warnings: string[] = [];
|
|
9
|
+
const accountIds = listLoggieAccountIds(params.cfg);
|
|
10
|
+
if (accountIds.length === 0) {
|
|
11
|
+
return ["channels.loggie has no configured accounts."];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
for (const accountId of accountIds) {
|
|
15
|
+
const account = resolveLoggieAccount(params.cfg, accountId, params.env);
|
|
16
|
+
const prefix = `channels.loggie account ${account.accountId}`;
|
|
17
|
+
if (!account.enabled) {
|
|
18
|
+
warnings.push(`${prefix} is disabled.`);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (!account.baseUrl) {
|
|
22
|
+
warnings.push(`${prefix} is missing baseUrl or LOGGIE_BASE_URL.`);
|
|
23
|
+
}
|
|
24
|
+
if (!account.agentProfileId) {
|
|
25
|
+
warnings.push(`${prefix} is missing agentProfileId.`);
|
|
26
|
+
}
|
|
27
|
+
if (account.tokenStatus.status !== "available") {
|
|
28
|
+
warnings.push(`${prefix} token is ${account.tokenStatus.status} (${account.tokenStatus.source}).`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return warnings;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const loggieDoctor = {
|
|
35
|
+
collectPreviewWarnings: (params: {
|
|
36
|
+
cfg: OpenClawConfig;
|
|
37
|
+
env?: NodeJS.ProcessEnv;
|
|
38
|
+
}) => collectLoggieDoctorWarnings(params),
|
|
39
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const LOGGIE_TRANSCRIPT_READY_EVENT = "meeting.transcript.ready";
|
|
4
|
+
|
|
5
|
+
const transcriptPayloadSchema = z
|
|
6
|
+
.object({
|
|
7
|
+
eventId: z.string().optional(),
|
|
8
|
+
cursor: z.string().optional(),
|
|
9
|
+
type: z.string().optional(),
|
|
10
|
+
workspaceId: z.string().optional(),
|
|
11
|
+
meetingId: z.string().optional(),
|
|
12
|
+
meetingScheduleId: z.string().optional(),
|
|
13
|
+
title: z.string().optional(),
|
|
14
|
+
source: z.string().nullable().optional(),
|
|
15
|
+
externalId: z.string().nullable().optional(),
|
|
16
|
+
meetingDate: z.string().datetime().optional(),
|
|
17
|
+
hasTranscript: z.boolean().optional(),
|
|
18
|
+
detailPath: z.string().optional(),
|
|
19
|
+
})
|
|
20
|
+
.passthrough();
|
|
21
|
+
|
|
22
|
+
export const loggieEventEnvelopeSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
eventId: z.string().min(1),
|
|
25
|
+
cursor: z.string().min(1).optional(),
|
|
26
|
+
workspaceId: z.string().min(1),
|
|
27
|
+
agentProfileId: z.string().min(1).optional(),
|
|
28
|
+
meetingId: z.string().min(1).optional(),
|
|
29
|
+
meetingScheduleId: z.string().min(1).optional(),
|
|
30
|
+
eventType: z.string().min(1),
|
|
31
|
+
occurredAt: z.string().datetime(),
|
|
32
|
+
sequence: z.number().int().nonnegative().optional(),
|
|
33
|
+
payload: z.record(z.unknown()).default({}),
|
|
34
|
+
})
|
|
35
|
+
.passthrough()
|
|
36
|
+
.refine((event) => Boolean(event.meetingId ?? event.meetingScheduleId), {
|
|
37
|
+
message: "meetingId or meetingScheduleId is required",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export type LoggieEventEnvelope = z.infer<typeof loggieEventEnvelopeSchema>;
|
|
41
|
+
export type LoggieTranscriptReadyEvent = LoggieEventEnvelope & {
|
|
42
|
+
eventType: typeof LOGGIE_TRANSCRIPT_READY_EVENT;
|
|
43
|
+
payload: z.infer<typeof transcriptPayloadSchema>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ParseLoggieEventResult =
|
|
47
|
+
| { ok: true; event: LoggieEventEnvelope }
|
|
48
|
+
| { ok: false; reason: string };
|
|
49
|
+
|
|
50
|
+
export function parseLoggieEventEnvelope(raw: unknown): ParseLoggieEventResult {
|
|
51
|
+
const candidate =
|
|
52
|
+
raw && typeof raw === "object" && "event" in raw && (raw as { event?: unknown }).event
|
|
53
|
+
? (raw as { event: unknown }).event
|
|
54
|
+
: raw;
|
|
55
|
+
const result = loggieEventEnvelopeSchema.safeParse(normalizeLoggieEventCandidate(candidate));
|
|
56
|
+
if (!result.success) {
|
|
57
|
+
return { ok: false, reason: result.error.issues.map((issue) => issue.message).join("; ") };
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, event: result.data };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isTranscriptReadyEvent(event: LoggieEventEnvelope): event is LoggieTranscriptReadyEvent {
|
|
63
|
+
if (event.eventType !== LOGGIE_TRANSCRIPT_READY_EVENT) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return transcriptPayloadSchema.safeParse(event.payload).success;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeLoggieEventCandidate(candidate: unknown): unknown {
|
|
70
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
71
|
+
return candidate;
|
|
72
|
+
}
|
|
73
|
+
const record = candidate as Record<string, unknown>;
|
|
74
|
+
const payload = record.payload && typeof record.payload === "object" && !Array.isArray(record.payload)
|
|
75
|
+
? (record.payload as Record<string, unknown>)
|
|
76
|
+
: {};
|
|
77
|
+
const eventType = readString(record.eventType) ?? readString(record.type);
|
|
78
|
+
const cursor = readString(record.cursor) ?? readString(payload.cursor);
|
|
79
|
+
const sequence = typeof record.sequence === "number" ? record.sequence : undefined;
|
|
80
|
+
const eventId =
|
|
81
|
+
readString(record.eventId) ??
|
|
82
|
+
readString(payload.eventId) ??
|
|
83
|
+
cursor;
|
|
84
|
+
return {
|
|
85
|
+
...record,
|
|
86
|
+
eventId,
|
|
87
|
+
cursor,
|
|
88
|
+
workspaceId: readString(record.workspaceId) ?? readString(payload.workspaceId),
|
|
89
|
+
agentProfileId: readString(record.agentProfileId) ?? readString(payload.agentProfileId),
|
|
90
|
+
meetingId: readString(record.meetingId) ?? readString(payload.meetingId),
|
|
91
|
+
meetingScheduleId: readString(record.meetingScheduleId) ?? readString(payload.meetingScheduleId),
|
|
92
|
+
eventType,
|
|
93
|
+
occurredAt: readString(record.occurredAt) ?? readString(record.createdAt),
|
|
94
|
+
sequence,
|
|
95
|
+
payload,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readString(value: unknown): string | undefined {
|
|
100
|
+
if (typeof value !== "string") {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const trimmed = value.trim();
|
|
104
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
105
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import WebSocket, { type ClientOptions, type RawData } from "ws";
|
|
2
|
+
import type { ResolvedLoggieAccount } from "./account.js";
|
|
3
|
+
import type { LoggieCursorRecord } from "./cursor-store.js";
|
|
4
|
+
|
|
5
|
+
export type LoggieSocket = {
|
|
6
|
+
readyState: number;
|
|
7
|
+
send(data: string): void;
|
|
8
|
+
close(code?: number, reason?: string): void;
|
|
9
|
+
on(event: "open", listener: () => void): unknown;
|
|
10
|
+
on(event: "message", listener: (data: RawData | string) => void): unknown;
|
|
11
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
12
|
+
on(event: "close", listener: (code: number, reason: Buffer) => void): unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type LoggieSocketFactory = (params: {
|
|
16
|
+
url: string;
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
}) => LoggieSocket;
|
|
19
|
+
|
|
20
|
+
export function buildLoggieWebSocketUrl(
|
|
21
|
+
account: Pick<ResolvedLoggieAccount, "baseUrl" | "socketPath">,
|
|
22
|
+
cursor?: LoggieCursorRecord,
|
|
23
|
+
): string {
|
|
24
|
+
const url = new URL(account.baseUrl);
|
|
25
|
+
url.protocol = url.protocol === "https:" ? "wss:" : url.protocol === "http:" ? "ws:" : url.protocol;
|
|
26
|
+
if (!url.protocol.startsWith("ws")) {
|
|
27
|
+
throw new Error(`Unsupported Loggie baseUrl protocol: ${url.protocol}`);
|
|
28
|
+
}
|
|
29
|
+
url.pathname = account.socketPath.startsWith("/") ? account.socketPath : `/${account.socketPath}`;
|
|
30
|
+
url.search = "";
|
|
31
|
+
const replayCursor = readReplayCursor(cursor);
|
|
32
|
+
if (replayCursor) {
|
|
33
|
+
url.searchParams.set("cursor", replayCursor);
|
|
34
|
+
}
|
|
35
|
+
return url.toString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildLoggieAuthHeaders(account: Pick<ResolvedLoggieAccount, "authHeader" | "tokenStatus">): Record<string, string> {
|
|
39
|
+
if (account.tokenStatus.status !== "available") {
|
|
40
|
+
throw new Error(`Loggie token is not available (${account.tokenStatus.status})`);
|
|
41
|
+
}
|
|
42
|
+
return account.authHeader === "x-api-key"
|
|
43
|
+
? { "x-api-key": account.tokenStatus.value }
|
|
44
|
+
: { authorization: `Bearer ${account.tokenStatus.value}` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildLoggieDetailUrl(
|
|
48
|
+
account: Pick<ResolvedLoggieAccount, "baseUrl">,
|
|
49
|
+
detailPath: string,
|
|
50
|
+
): string {
|
|
51
|
+
const base = new URL(account.baseUrl);
|
|
52
|
+
const url = new URL(detailPath, base);
|
|
53
|
+
if (url.origin !== base.origin) {
|
|
54
|
+
throw new Error("Loggie transcript detail URL must stay on the configured Loggie origin");
|
|
55
|
+
}
|
|
56
|
+
return url.toString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function fetchLoggieTranscriptDetail(params: {
|
|
60
|
+
account: Pick<ResolvedLoggieAccount, "baseUrl" | "authHeader" | "tokenStatus">;
|
|
61
|
+
detailPath: string;
|
|
62
|
+
fetchImpl?: typeof fetch;
|
|
63
|
+
}): Promise<unknown> {
|
|
64
|
+
const fetchImpl = params.fetchImpl ?? fetch;
|
|
65
|
+
const response = await fetchImpl(buildLoggieDetailUrl(params.account, params.detailPath), {
|
|
66
|
+
headers: buildLoggieAuthHeaders(params.account),
|
|
67
|
+
});
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new Error(`Loggie transcript detail fetch failed with HTTP ${response.status}`);
|
|
70
|
+
}
|
|
71
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
72
|
+
if (contentType.includes("application/json")) {
|
|
73
|
+
return response.json();
|
|
74
|
+
}
|
|
75
|
+
return response.text();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const defaultLoggieSocketFactory: LoggieSocketFactory = ({ url, headers }) => {
|
|
79
|
+
const options: ClientOptions = { headers };
|
|
80
|
+
return new WebSocket(url, options);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function buildLoggiePingMessage(): { type: "ping" } {
|
|
84
|
+
return { type: "ping" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildLoggieResumeMessage(cursor: string): { type: "resume"; cursor: string } {
|
|
88
|
+
return { type: "resume", cursor };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function decodeLoggieSocketMessage(data: RawData | string): unknown {
|
|
92
|
+
const text =
|
|
93
|
+
typeof data === "string"
|
|
94
|
+
? data
|
|
95
|
+
: Buffer.isBuffer(data)
|
|
96
|
+
? data.toString("utf8")
|
|
97
|
+
: Array.isArray(data)
|
|
98
|
+
? Buffer.concat(data).toString("utf8")
|
|
99
|
+
: Buffer.from(data).toString("utf8");
|
|
100
|
+
return JSON.parse(text);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readReplayCursor(cursor: LoggieCursorRecord | undefined): string | undefined {
|
|
104
|
+
if (!cursor) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
if (cursor.lastCursor) {
|
|
108
|
+
return cursor.lastCursor;
|
|
109
|
+
}
|
|
110
|
+
return typeof cursor.lastSequence === "number" ? String(cursor.lastSequence) : undefined;
|
|
111
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/channel-core";
|
|
2
|
+
import type { ResolvedLoggieAccount } from "../account.js";
|
|
3
|
+
import { createRuntimeCursorStore, type LoggieCursorStore } from "../cursor-store.js";
|
|
4
|
+
import {
|
|
5
|
+
buildLoggieAuthHeaders,
|
|
6
|
+
buildLoggiePingMessage,
|
|
7
|
+
buildLoggieResumeMessage,
|
|
8
|
+
buildLoggieWebSocketUrl,
|
|
9
|
+
decodeLoggieSocketMessage,
|
|
10
|
+
defaultLoggieSocketFactory,
|
|
11
|
+
type LoggieSocketFactory,
|
|
12
|
+
} from "../loggie-client.js";
|
|
13
|
+
import { createOpenClawTranscriptDispatcher, type TranscriptDispatcher } from "./transcript-dispatch.js";
|
|
14
|
+
import { handleLoggieEvent } from "./event-handler.js";
|
|
15
|
+
|
|
16
|
+
type ChannelRuntime = PluginRuntime["channel"];
|
|
17
|
+
|
|
18
|
+
export type MonitorLoggieAccountParams = {
|
|
19
|
+
account: ResolvedLoggieAccount;
|
|
20
|
+
cfg: OpenClawConfig;
|
|
21
|
+
runtime: PluginRuntime;
|
|
22
|
+
channelRuntime?: ChannelRuntime;
|
|
23
|
+
abortSignal?: AbortSignal;
|
|
24
|
+
setStatus?: (next: Record<string, unknown>) => void;
|
|
25
|
+
getStatus?: () => Record<string, unknown>;
|
|
26
|
+
log?: {
|
|
27
|
+
info?: (message: string) => void;
|
|
28
|
+
warn?: (message: string) => void;
|
|
29
|
+
};
|
|
30
|
+
cursorStore?: LoggieCursorStore;
|
|
31
|
+
dispatcher?: TranscriptDispatcher;
|
|
32
|
+
socketFactory?: LoggieSocketFactory;
|
|
33
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export async function monitorLoggieAccount(params: MonitorLoggieAccountParams): Promise<void> {
|
|
37
|
+
if (!params.account.enabled) {
|
|
38
|
+
params.log?.info?.(`[${params.account.accountId}] Loggie account disabled`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!params.account.configured) {
|
|
42
|
+
throw new Error(`Loggie account ${params.account.accountId} is not configured`);
|
|
43
|
+
}
|
|
44
|
+
if (!params.channelRuntime && !params.dispatcher) {
|
|
45
|
+
throw new Error("Loggie channelRuntime is required for transcript dispatch");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const cursorStore = params.cursorStore ?? createRuntimeCursorStore(params.runtime);
|
|
49
|
+
const dispatcher =
|
|
50
|
+
params.dispatcher ??
|
|
51
|
+
createOpenClawTranscriptDispatcher({
|
|
52
|
+
account: params.account,
|
|
53
|
+
cfg: params.cfg,
|
|
54
|
+
runtime: params.runtime,
|
|
55
|
+
channelRuntime: params.channelRuntime!,
|
|
56
|
+
abortSignal: params.abortSignal,
|
|
57
|
+
});
|
|
58
|
+
const sleep = params.sleep ?? defaultSleep;
|
|
59
|
+
let attempts = 0;
|
|
60
|
+
|
|
61
|
+
while (!params.abortSignal?.aborted) {
|
|
62
|
+
try {
|
|
63
|
+
await runLoggieSocketOnce({
|
|
64
|
+
...params,
|
|
65
|
+
cursorStore,
|
|
66
|
+
dispatcher,
|
|
67
|
+
socketFactory: params.socketFactory ?? defaultLoggieSocketFactory,
|
|
68
|
+
});
|
|
69
|
+
attempts = 0;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (params.abortSignal?.aborted) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
attempts += 1;
|
|
75
|
+
const delayMs = Math.min(
|
|
76
|
+
params.account.reconnect.maxMs,
|
|
77
|
+
params.account.reconnect.minMs * 2 ** Math.max(0, attempts - 1),
|
|
78
|
+
);
|
|
79
|
+
params.setStatus?.({
|
|
80
|
+
...(params.getStatus?.() ?? { accountId: params.account.accountId }),
|
|
81
|
+
running: true,
|
|
82
|
+
connected: false,
|
|
83
|
+
authenticated: false,
|
|
84
|
+
reconnectAttempts: attempts,
|
|
85
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
86
|
+
});
|
|
87
|
+
params.log?.warn?.(
|
|
88
|
+
`[${params.account.accountId}] Loggie socket failed; reconnecting in ${delayMs}ms`,
|
|
89
|
+
);
|
|
90
|
+
await sleep(delayMs, params.abortSignal);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function runLoggieSocketOnce(params: MonitorLoggieAccountParams & {
|
|
96
|
+
cursorStore: LoggieCursorStore;
|
|
97
|
+
dispatcher: TranscriptDispatcher;
|
|
98
|
+
socketFactory: LoggieSocketFactory;
|
|
99
|
+
}): Promise<void> {
|
|
100
|
+
const cursor = await params.cursorStore.load(params.account.accountId, params.account.agentProfileId);
|
|
101
|
+
const ws = params.socketFactory({
|
|
102
|
+
url: buildLoggieWebSocketUrl(params.account, cursor),
|
|
103
|
+
headers: buildLoggieAuthHeaders(params.account),
|
|
104
|
+
});
|
|
105
|
+
params.setStatus?.({
|
|
106
|
+
...(params.getStatus?.() ?? { accountId: params.account.accountId }),
|
|
107
|
+
running: true,
|
|
108
|
+
connected: false,
|
|
109
|
+
authenticated: false,
|
|
110
|
+
lastStartAt: Date.now(),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await new Promise<void>((resolve, reject) => {
|
|
114
|
+
let settled = false;
|
|
115
|
+
let stoppedByAbort = false;
|
|
116
|
+
let heartbeatTimeout: NodeJS.Timeout | undefined;
|
|
117
|
+
let pingInterval: NodeJS.Timeout | undefined;
|
|
118
|
+
let eventQueue = Promise.resolve();
|
|
119
|
+
const pendingMessages = new Set<Promise<void>>();
|
|
120
|
+
const clearHeartbeat = () => {
|
|
121
|
+
if (heartbeatTimeout) {
|
|
122
|
+
clearTimeout(heartbeatTimeout);
|
|
123
|
+
heartbeatTimeout = undefined;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const clearPing = () => {
|
|
127
|
+
if (pingInterval) {
|
|
128
|
+
clearInterval(pingInterval);
|
|
129
|
+
pingInterval = undefined;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const finish = (fn: () => void) => {
|
|
133
|
+
if (settled) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
settled = true;
|
|
137
|
+
clearHeartbeat();
|
|
138
|
+
clearPing();
|
|
139
|
+
params.abortSignal?.removeEventListener("abort", closeOnAbort);
|
|
140
|
+
fn();
|
|
141
|
+
};
|
|
142
|
+
const failSocket = (error: Error, options?: { waitForPending?: boolean }) => {
|
|
143
|
+
clearPing();
|
|
144
|
+
try {
|
|
145
|
+
ws.close(1011, error.message);
|
|
146
|
+
} catch {
|
|
147
|
+
// Ignore close errors and report the original failure.
|
|
148
|
+
}
|
|
149
|
+
if (options?.waitForPending) {
|
|
150
|
+
void Promise.allSettled([...pendingMessages]).then(() => finish(() => reject(error)));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
finish(() => reject(error));
|
|
154
|
+
};
|
|
155
|
+
const resetHeartbeat = () => {
|
|
156
|
+
clearHeartbeat();
|
|
157
|
+
heartbeatTimeout = setTimeout(() => {
|
|
158
|
+
failSocket(new Error("Loggie heartbeat timeout"), { waitForPending: true });
|
|
159
|
+
}, params.account.heartbeat.timeoutMs);
|
|
160
|
+
heartbeatTimeout.unref?.();
|
|
161
|
+
};
|
|
162
|
+
const startPing = () => {
|
|
163
|
+
clearPing();
|
|
164
|
+
const intervalMs = Math.max(250, Math.floor(params.account.heartbeat.timeoutMs / 2));
|
|
165
|
+
pingInterval = setInterval(() => {
|
|
166
|
+
try {
|
|
167
|
+
ws.send(JSON.stringify(buildLoggiePingMessage()));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
failSocket(error instanceof Error ? error : new Error(String(error)), { waitForPending: true });
|
|
170
|
+
}
|
|
171
|
+
}, intervalMs);
|
|
172
|
+
pingInterval.unref?.();
|
|
173
|
+
};
|
|
174
|
+
const closeOnAbort = () => {
|
|
175
|
+
stoppedByAbort = true;
|
|
176
|
+
ws.close(1000, "OpenClaw Loggie monitor stopped");
|
|
177
|
+
};
|
|
178
|
+
params.abortSignal?.addEventListener("abort", closeOnAbort, { once: true });
|
|
179
|
+
ws.on("open", () => {
|
|
180
|
+
resetHeartbeat();
|
|
181
|
+
startPing();
|
|
182
|
+
void (async () => {
|
|
183
|
+
const connectedAt = Date.now();
|
|
184
|
+
params.setStatus?.({
|
|
185
|
+
...(params.getStatus?.() ?? { accountId: params.account.accountId }),
|
|
186
|
+
running: true,
|
|
187
|
+
connected: true,
|
|
188
|
+
authenticated: true,
|
|
189
|
+
reconnectAttempts: 0,
|
|
190
|
+
lastConnectedAt: connectedAt,
|
|
191
|
+
lastError: null,
|
|
192
|
+
});
|
|
193
|
+
await params.cursorStore.save({
|
|
194
|
+
...(cursor ?? {
|
|
195
|
+
accountId: params.account.accountId,
|
|
196
|
+
agentProfileId: params.account.agentProfileId,
|
|
197
|
+
seenEventIds: [],
|
|
198
|
+
updatedAt: new Date(connectedAt).toISOString(),
|
|
199
|
+
}),
|
|
200
|
+
lastConnectedAt: connectedAt,
|
|
201
|
+
updatedAt: new Date(connectedAt).toISOString(),
|
|
202
|
+
});
|
|
203
|
+
})().catch((error) => {
|
|
204
|
+
ws.close(1011, error instanceof Error ? error.message : "Loggie open initialization failed");
|
|
205
|
+
finish(() => reject(error));
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
ws.on("message", (data) => {
|
|
209
|
+
resetHeartbeat();
|
|
210
|
+
let decoded: unknown;
|
|
211
|
+
try {
|
|
212
|
+
decoded = decodeLoggieSocketMessage(data);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
failSocket(error instanceof Error ? error : new Error(String(error)));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const control = parseControlMessage(decoded);
|
|
218
|
+
if (control.type === "ignore") {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (control.type === "resume") {
|
|
222
|
+
try {
|
|
223
|
+
ws.send(JSON.stringify(buildLoggieResumeMessage(control.cursor)));
|
|
224
|
+
} catch (error) {
|
|
225
|
+
failSocket(error instanceof Error ? error : new Error(String(error)), { waitForPending: true });
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (control.type === "error") {
|
|
230
|
+
failSocket(new Error(control.error), { waitForPending: true });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const task = eventQueue.then(async () => {
|
|
234
|
+
const result = await handleLoggieEvent({
|
|
235
|
+
raw: decoded,
|
|
236
|
+
account: params.account,
|
|
237
|
+
cursorStore: params.cursorStore,
|
|
238
|
+
dispatcher: params.dispatcher,
|
|
239
|
+
});
|
|
240
|
+
if (result.status === "failed") {
|
|
241
|
+
throw new Error(result.reason);
|
|
242
|
+
}
|
|
243
|
+
const eventAt = Date.now();
|
|
244
|
+
const deadLettered = result.status === "skipped" ? result.deadLettered : undefined;
|
|
245
|
+
if (deadLettered && result.event) {
|
|
246
|
+
params.log?.warn?.(
|
|
247
|
+
`[${params.account.accountId}] Loggie event ${result.event.eventId} dead-lettered after ${deadLettered.attempts} attempts: ${deadLettered.reason}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
params.setStatus?.({
|
|
251
|
+
...(params.getStatus?.() ?? { accountId: params.account.accountId }),
|
|
252
|
+
running: true,
|
|
253
|
+
connected: true,
|
|
254
|
+
authenticated: true,
|
|
255
|
+
caughtUp: true,
|
|
256
|
+
lastEventAt: eventAt,
|
|
257
|
+
...(result.status === "dispatched" ? { lastDispatchAt: eventAt } : {}),
|
|
258
|
+
...(deadLettered && result.event
|
|
259
|
+
? {
|
|
260
|
+
lastDeadLetterAt: eventAt,
|
|
261
|
+
lastDeadLetteredEventId: result.event.eventId,
|
|
262
|
+
lastDeadLetteredCursor: result.event.cursor ?? null,
|
|
263
|
+
lastDeadLetterReason: deadLettered.reason,
|
|
264
|
+
lastDeadLetterAttempts: deadLettered.attempts,
|
|
265
|
+
}
|
|
266
|
+
: {}),
|
|
267
|
+
lastEventId: result.event?.eventId ?? null,
|
|
268
|
+
lastCursor: result.event?.cursor ?? null,
|
|
269
|
+
lastSequence: result.event?.sequence ?? null,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
eventQueue = task.catch(() => undefined);
|
|
273
|
+
pendingMessages.add(task);
|
|
274
|
+
void task
|
|
275
|
+
.catch((error) => {
|
|
276
|
+
ws.close(1011, error instanceof Error ? error.message : "Loggie event handling failed");
|
|
277
|
+
finish(() => reject(error));
|
|
278
|
+
})
|
|
279
|
+
.finally(() => {
|
|
280
|
+
pendingMessages.delete(task);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
ws.on("error", (error) => finish(() => reject(error)));
|
|
284
|
+
ws.on("close", (code, reason) => {
|
|
285
|
+
clearHeartbeat();
|
|
286
|
+
clearPing();
|
|
287
|
+
const reasonText = reason.toString("utf8");
|
|
288
|
+
void Promise.allSettled([...pendingMessages]).then(() => {
|
|
289
|
+
if (stoppedByAbort || params.abortSignal?.aborted) {
|
|
290
|
+
finish(resolve);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
finish(() =>
|
|
294
|
+
reject(
|
|
295
|
+
new Error(
|
|
296
|
+
`Loggie socket closed unexpectedly (${code}${reasonText ? `: ${reasonText}` : ""})`,
|
|
297
|
+
),
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
type ControlMessageDecision =
|
|
306
|
+
| { type: "none" }
|
|
307
|
+
| { type: "ignore" }
|
|
308
|
+
| { type: "resume"; cursor: string }
|
|
309
|
+
| { type: "error"; error: string };
|
|
310
|
+
|
|
311
|
+
function parseControlMessage(value: unknown): ControlMessageDecision {
|
|
312
|
+
if (value === null || typeof value !== "object" || !("type" in value)) {
|
|
313
|
+
return { type: "none" };
|
|
314
|
+
}
|
|
315
|
+
const record = value as { type?: unknown; cursor?: unknown; error?: unknown };
|
|
316
|
+
if (record.type === "hello" || record.type === "pong" || record.type === "ack") {
|
|
317
|
+
return { type: "ignore" };
|
|
318
|
+
}
|
|
319
|
+
if (record.type === "loggie.events.more_available") {
|
|
320
|
+
return typeof record.cursor === "string" && record.cursor.trim().length > 0
|
|
321
|
+
? { type: "resume", cursor: record.cursor.trim() }
|
|
322
|
+
: { type: "error", error: "Loggie replay cursor missing from more_available message" };
|
|
323
|
+
}
|
|
324
|
+
if (record.type === "error") {
|
|
325
|
+
return {
|
|
326
|
+
type: "error",
|
|
327
|
+
error: typeof record.error === "string" ? record.error : "Loggie socket returned an error",
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return { type: "none" };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
334
|
+
return new Promise((resolve) => {
|
|
335
|
+
if (signal?.aborted) {
|
|
336
|
+
resolve();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const timeout = setTimeout(resolve, ms);
|
|
340
|
+
signal?.addEventListener(
|
|
341
|
+
"abort",
|
|
342
|
+
() => {
|
|
343
|
+
clearTimeout(timeout);
|
|
344
|
+
resolve();
|
|
345
|
+
},
|
|
346
|
+
{ once: true },
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
}
|