clawconnect-agent 0.1.39 → 0.1.41
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 +11 -0
- package/README.zh-CN.md +10 -0
- package/dist/commands/install.js +7 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/pair.d.ts +1 -2
- package/dist/commands/pair.js +2 -16
- package/dist/commands/pair.js.map +1 -1
- package/dist/commands/run.js +26 -13
- package/dist/commands/run.js.map +1 -1
- package/dist/core/command-types.d.ts +2 -1
- package/dist/core/relay/assistant-stream-coalescer.d.ts +67 -0
- package/dist/core/relay/assistant-stream-coalescer.js +269 -0
- package/dist/core/relay/assistant-stream-coalescer.js.map +1 -0
- package/dist/core/relay/file-upload-utils.d.ts +1 -0
- package/dist/core/relay/file-upload-utils.js +15 -0
- package/dist/core/relay/file-upload-utils.js.map +1 -1
- package/dist/core/relay/office-payload.d.ts +1 -0
- package/dist/core/relay/office-payload.js +42 -3
- package/dist/core/relay/office-payload.js.map +1 -1
- package/dist/core/relay/reconnect.d.ts +1 -0
- package/dist/core/relay/reconnect.js +22 -4
- package/dist/core/relay/reconnect.js.map +1 -1
- package/dist/core/relay/relay-server-connection.d.ts +25 -1
- package/dist/core/relay/relay-server-connection.js +246 -3
- package/dist/core/relay/relay-server-connection.js.map +1 -1
- package/dist/core/relay/reliable-delivery-protocol.d.ts +17 -0
- package/dist/core/relay/reliable-delivery-protocol.js +25 -0
- package/dist/core/relay/reliable-delivery-protocol.js.map +1 -0
- package/dist/core/relay/reliable-relay-outbox-registry.d.ts +19 -0
- package/dist/core/relay/reliable-relay-outbox-registry.js +81 -0
- package/dist/core/relay/reliable-relay-outbox-registry.js.map +1 -0
- package/dist/core/relay/reliable-relay-outbox-store.d.ts +67 -0
- package/dist/core/relay/reliable-relay-outbox-store.js +527 -0
- package/dist/core/relay/reliable-relay-outbox-store.js.map +1 -0
- package/dist/core/relay/reliable-relay-outbox.d.ts +74 -0
- package/dist/core/relay/reliable-relay-outbox.js +442 -0
- package/dist/core/relay/reliable-relay-outbox.js.map +1 -0
- package/dist/core/relay/timeline-event-builder.d.ts +1 -0
- package/dist/core/relay/timeline-event-builder.js +1 -0
- package/dist/core/relay/timeline-event-builder.js.map +1 -1
- package/dist/hermes/hermes-relay-manager.d.ts +5 -0
- package/dist/hermes/hermes-relay-manager.js +105 -8
- package/dist/hermes/hermes-relay-manager.js.map +1 -1
- package/dist/hermes/runtime/hermes-runtime-api-client.js +54 -29
- package/dist/hermes/runtime/hermes-runtime-api-client.js.map +1 -1
- package/dist/openclaw/gateway-client.d.ts +1 -0
- package/dist/openclaw/gateway-client.js +27 -3
- package/dist/openclaw/gateway-client.js.map +1 -1
- package/dist/openclaw/relay/chat-history.js +18 -7
- package/dist/openclaw/relay/chat-history.js.map +1 -1
- package/dist/openclaw/relay/chat-run-identity.d.ts +19 -2
- package/dist/openclaw/relay/chat-run-identity.js +89 -4
- package/dist/openclaw/relay/chat-run-identity.js.map +1 -1
- package/dist/openclaw/relay/relay-manager-protocol.d.ts +24 -3
- package/dist/openclaw/relay-manager.js +264 -36
- package/dist/openclaw/relay-manager.js.map +1 -1
- package/dist/platform/service-manager-common.d.ts +4 -0
- package/dist/platform/service-manager-common.js +14 -1
- package/dist/platform/service-manager-common.js.map +1 -1
- package/dist/platform/service-manager-linux.d.ts +17 -5
- package/dist/platform/service-manager-linux.js +86 -73
- package/dist/platform/service-manager-linux.js.map +1 -1
- package/dist/platform/service-manager.js +21 -17
- package/dist/platform/service-manager.js.map +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { normalizeRelayServerIdentity } from "./file-upload-utils.js";
|
|
3
|
+
import { ReliableRelayOutbox } from "./reliable-relay-outbox.js";
|
|
4
|
+
import { reliableRelayOutboxStorageDirectory } from "./reliable-relay-outbox-store.js";
|
|
5
|
+
export const RELIABLE_RELAY_OUTBOX_MAX_GATEWAYS = 64;
|
|
6
|
+
const outboxesByNamespace = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* Relay、profile 存储目录和 gateway 共同定义唯一队列,禁止跨 Relay 误重放。
|
|
9
|
+
*/
|
|
10
|
+
export function reliableRelayOutboxForGateway(gatewayId, options = {}) {
|
|
11
|
+
const normalized = typeof gatewayId === "string" ? gatewayId.trim() : "";
|
|
12
|
+
if (!normalized) {
|
|
13
|
+
return rejectedLookup("gateway_id_required", "reliable_relay_gateway_id_required");
|
|
14
|
+
}
|
|
15
|
+
const relayIdentity = options.relayIdentity?.trim();
|
|
16
|
+
if (!relayIdentity) {
|
|
17
|
+
return rejectedLookup("relay_identity_required", "reliable_relay_relay_identity_required");
|
|
18
|
+
}
|
|
19
|
+
let canonicalRelayIdentity;
|
|
20
|
+
try {
|
|
21
|
+
canonicalRelayIdentity = normalizeRelayServerIdentity(relayIdentity);
|
|
22
|
+
}
|
|
23
|
+
catch (value) {
|
|
24
|
+
return rejectedLookup("relay_identity_required", `reliable_relay_relay_identity_invalid: ${asError(value).message}`);
|
|
25
|
+
}
|
|
26
|
+
const storageDirectory = options.storageDirectory ?? reliableRelayOutboxStorageDirectory();
|
|
27
|
+
const registryKey = `${resolve(storageDirectory)}\u0000${canonicalRelayIdentity}\u0000${normalized}`;
|
|
28
|
+
const existing = outboxesByNamespace.get(registryKey);
|
|
29
|
+
if (existing) {
|
|
30
|
+
outboxesByNamespace.delete(registryKey);
|
|
31
|
+
outboxesByNamespace.set(registryKey, existing);
|
|
32
|
+
return { status: "ready", outbox: existing };
|
|
33
|
+
}
|
|
34
|
+
pruneIdleOutboxes();
|
|
35
|
+
if (outboxesByNamespace.size >= RELIABLE_RELAY_OUTBOX_MAX_GATEWAYS) {
|
|
36
|
+
return rejectedLookup("gateway_limit_reached", "reliable_relay_gateway_outbox_limit_reached");
|
|
37
|
+
}
|
|
38
|
+
let created;
|
|
39
|
+
try {
|
|
40
|
+
created = new ReliableRelayOutbox(normalized, {
|
|
41
|
+
storageDirectory,
|
|
42
|
+
relayIdentity: canonicalRelayIdentity,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (value) {
|
|
46
|
+
const error = asError(value);
|
|
47
|
+
return rejectedLookup("storage_unavailable", `reliable_relay_outbox_storage_unavailable: ${error.message}`);
|
|
48
|
+
}
|
|
49
|
+
outboxesByNamespace.set(registryKey, created);
|
|
50
|
+
return { status: "ready", outbox: created };
|
|
51
|
+
}
|
|
52
|
+
export function clearReliableRelayOutboxesForTests() {
|
|
53
|
+
disposeReliableRelayOutboxes();
|
|
54
|
+
}
|
|
55
|
+
export function disposeReliableRelayOutboxes() {
|
|
56
|
+
for (const outbox of outboxesByNamespace.values())
|
|
57
|
+
outbox.dispose();
|
|
58
|
+
outboxesByNamespace.clear();
|
|
59
|
+
}
|
|
60
|
+
function rejectedLookup(reason, message) {
|
|
61
|
+
const error = new Error(message);
|
|
62
|
+
console.warn(`[relay] reliable outbox unavailable: ${message}`);
|
|
63
|
+
return { status: "rejected", reason, error };
|
|
64
|
+
}
|
|
65
|
+
function pruneIdleOutboxes() {
|
|
66
|
+
if (outboxesByNamespace.size < RELIABLE_RELAY_OUTBOX_MAX_GATEWAYS)
|
|
67
|
+
return;
|
|
68
|
+
for (const [namespace, outbox] of outboxesByNamespace) {
|
|
69
|
+
// 在线连接即使暂时为空也仍拥有该队列,不能为了容量回收关闭其持久化存储。
|
|
70
|
+
if (outbox.isIdleForRegistryEviction) {
|
|
71
|
+
outbox.dispose();
|
|
72
|
+
outboxesByNamespace.delete(namespace);
|
|
73
|
+
if (outboxesByNamespace.size < RELIABLE_RELAY_OUTBOX_MAX_GATEWAYS)
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function asError(value) {
|
|
79
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=reliable-relay-outbox-registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reliable-relay-outbox-registry.js","sourceRoot":"","sources":["../../../src/core/relay/reliable-relay-outbox-registry.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type StoredReliableRelayOutboxEntry = {
|
|
2
|
+
key: string;
|
|
3
|
+
message: unknown;
|
|
4
|
+
byteLength: number;
|
|
5
|
+
contentHash: string;
|
|
6
|
+
mode: "event_ack" | "response_ack";
|
|
7
|
+
deliveryId?: string;
|
|
8
|
+
};
|
|
9
|
+
export interface ReliableRelayOutboxStore {
|
|
10
|
+
load(): readonly StoredReliableRelayOutboxEntry[];
|
|
11
|
+
put(entry: StoredReliableRelayOutboxEntry): void;
|
|
12
|
+
remove(key: string): void;
|
|
13
|
+
close(): void;
|
|
14
|
+
}
|
|
15
|
+
export type FileReliableRelayOutboxStoreOptions = {
|
|
16
|
+
directory: string;
|
|
17
|
+
gatewayId: string;
|
|
18
|
+
relayIdentity: string;
|
|
19
|
+
onMaintenanceError?: (error: Error) => void;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Single-writer, append-only WAL for one profile/gateway outbox.
|
|
23
|
+
*
|
|
24
|
+
* Every mutation is fsync'd before returning. Compaction writes a complete,
|
|
25
|
+
* checkpointed next generation before retiring the previous one, so a crash
|
|
26
|
+
* can leave at most a truncated final record or an incomplete higher
|
|
27
|
+
* generation; startup safely falls back to the newest complete checkpoint.
|
|
28
|
+
*
|
|
29
|
+
* 每次变更返回前必须完成 fsync;压缩时先写完整的新代 checkpoint,再淘汰旧代,
|
|
30
|
+
* 因而崩溃只会留下可忽略的尾部残片或不完整的新代,不会覆盖最后一个有效状态。
|
|
31
|
+
*/
|
|
32
|
+
export declare class FileReliableRelayOutboxStore implements ReliableRelayOutboxStore {
|
|
33
|
+
private readonly options;
|
|
34
|
+
private readonly gatewayHash;
|
|
35
|
+
private readonly filePrefix;
|
|
36
|
+
private readonly lockPath;
|
|
37
|
+
private readonly operationLockPath;
|
|
38
|
+
private readonly lockToken;
|
|
39
|
+
private readonly onMaintenanceError?;
|
|
40
|
+
private readonly entries;
|
|
41
|
+
private lockFd?;
|
|
42
|
+
private journalFd?;
|
|
43
|
+
private journalPath;
|
|
44
|
+
private generation;
|
|
45
|
+
private journalBytes;
|
|
46
|
+
private closed;
|
|
47
|
+
private failed;
|
|
48
|
+
constructor(options: FileReliableRelayOutboxStoreOptions);
|
|
49
|
+
load(): readonly StoredReliableRelayOutboxEntry[];
|
|
50
|
+
put(entry: StoredReliableRelayOutboxEntry): void;
|
|
51
|
+
remove(key: string): void;
|
|
52
|
+
close(): void;
|
|
53
|
+
private acquireLock;
|
|
54
|
+
private acquireOperationLock;
|
|
55
|
+
private releaseOperationLock;
|
|
56
|
+
private openNewestGeneration;
|
|
57
|
+
private generationPaths;
|
|
58
|
+
private append;
|
|
59
|
+
private compactIfNeeded;
|
|
60
|
+
private activateNewGeneration;
|
|
61
|
+
private removeInactiveGenerations;
|
|
62
|
+
private nextGeneration;
|
|
63
|
+
private assertUsable;
|
|
64
|
+
private reportMaintenanceError;
|
|
65
|
+
}
|
|
66
|
+
export declare function reliableRelayOutboxStorageDirectory(profile?: string): string;
|
|
67
|
+
export declare function clearReliableRelayOutboxStorage(profile?: string): void;
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, fstatSync, fsyncSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeSync, } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { profileRoot } from "../../config/profile.js";
|
|
5
|
+
import { setRestrictiveDirPermissions, setRestrictiveFilePermissions, } from "../../platform/service-manager-common.js";
|
|
6
|
+
import { normalizeRelayServerIdentity } from "./file-upload-utils.js";
|
|
7
|
+
const STORE_VERSION = 1;
|
|
8
|
+
const STORE_DIR_NAME = "reliable-outbox-v1";
|
|
9
|
+
const COMPACTION_MIN_BYTES = 1024 * 1024;
|
|
10
|
+
const COMPACTION_HEADROOM_BYTES = 256 * 1024;
|
|
11
|
+
const MAX_JOURNAL_BYTES = 256 * 1024 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Single-writer, append-only WAL for one profile/gateway outbox.
|
|
14
|
+
*
|
|
15
|
+
* Every mutation is fsync'd before returning. Compaction writes a complete,
|
|
16
|
+
* checkpointed next generation before retiring the previous one, so a crash
|
|
17
|
+
* can leave at most a truncated final record or an incomplete higher
|
|
18
|
+
* generation; startup safely falls back to the newest complete checkpoint.
|
|
19
|
+
*
|
|
20
|
+
* 每次变更返回前必须完成 fsync;压缩时先写完整的新代 checkpoint,再淘汰旧代,
|
|
21
|
+
* 因而崩溃只会留下可忽略的尾部残片或不完整的新代,不会覆盖最后一个有效状态。
|
|
22
|
+
*/
|
|
23
|
+
export class FileReliableRelayOutboxStore {
|
|
24
|
+
options;
|
|
25
|
+
gatewayHash;
|
|
26
|
+
filePrefix;
|
|
27
|
+
lockPath;
|
|
28
|
+
operationLockPath;
|
|
29
|
+
lockToken = randomUUID();
|
|
30
|
+
onMaintenanceError;
|
|
31
|
+
entries = new Map();
|
|
32
|
+
lockFd;
|
|
33
|
+
journalFd;
|
|
34
|
+
journalPath = "";
|
|
35
|
+
generation = 0;
|
|
36
|
+
journalBytes = 0;
|
|
37
|
+
closed = false;
|
|
38
|
+
failed = false;
|
|
39
|
+
constructor(options) {
|
|
40
|
+
this.options = options;
|
|
41
|
+
this.gatewayHash = createHash("sha256")
|
|
42
|
+
.update(normalizeRelayServerIdentity(options.relayIdentity))
|
|
43
|
+
.update("\u0000")
|
|
44
|
+
.update(options.gatewayId)
|
|
45
|
+
.digest("hex");
|
|
46
|
+
this.filePrefix = `${this.gatewayHash}.`;
|
|
47
|
+
this.lockPath = join(options.directory, `${this.gatewayHash}.lock`);
|
|
48
|
+
this.operationLockPath = operationLockPath(options.directory);
|
|
49
|
+
this.onMaintenanceError = options.onMaintenanceError;
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(options.directory, { recursive: true, mode: 0o700 });
|
|
52
|
+
setRestrictiveDirPermissions(options.directory);
|
|
53
|
+
this.acquireOperationLock();
|
|
54
|
+
try {
|
|
55
|
+
this.acquireLock();
|
|
56
|
+
this.openNewestGeneration();
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
this.releaseOperationLock();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
this.close();
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
load() {
|
|
68
|
+
this.assertUsable();
|
|
69
|
+
return [...this.entries.values()];
|
|
70
|
+
}
|
|
71
|
+
put(entry) {
|
|
72
|
+
this.assertUsable();
|
|
73
|
+
if (this.entries.has(entry.key)) {
|
|
74
|
+
throw new Error(`reliable_outbox_store_duplicate_put key=${entry.key}`);
|
|
75
|
+
}
|
|
76
|
+
this.append({ v: STORE_VERSION, kind: "put", entry });
|
|
77
|
+
this.entries.set(entry.key, entry);
|
|
78
|
+
this.compactIfNeeded();
|
|
79
|
+
}
|
|
80
|
+
remove(key) {
|
|
81
|
+
this.assertUsable();
|
|
82
|
+
if (!this.entries.has(key)) {
|
|
83
|
+
throw new Error(`reliable_outbox_store_missing_remove key=${key}`);
|
|
84
|
+
}
|
|
85
|
+
this.append({ v: STORE_VERSION, kind: "remove", key });
|
|
86
|
+
this.entries.delete(key);
|
|
87
|
+
this.compactIfNeeded();
|
|
88
|
+
}
|
|
89
|
+
close() {
|
|
90
|
+
if (this.closed)
|
|
91
|
+
return;
|
|
92
|
+
this.closed = true;
|
|
93
|
+
if (this.journalFd !== undefined) {
|
|
94
|
+
try {
|
|
95
|
+
closeSync(this.journalFd);
|
|
96
|
+
}
|
|
97
|
+
catch { /* best effort */ }
|
|
98
|
+
this.journalFd = undefined;
|
|
99
|
+
}
|
|
100
|
+
if (this.lockFd !== undefined) {
|
|
101
|
+
try {
|
|
102
|
+
closeSync(this.lockFd);
|
|
103
|
+
}
|
|
104
|
+
catch { /* best effort */ }
|
|
105
|
+
this.lockFd = undefined;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const lock = JSON.parse(readFileSync(this.lockPath, "utf8"));
|
|
109
|
+
if (lock.token === this.lockToken)
|
|
110
|
+
unlinkSync(this.lockPath);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// A crashed process leaves a stale lock which the next owner repairs.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
acquireLock() {
|
|
117
|
+
// 操作锁把“检查旧 owner → 删除陈旧锁 → 创建新 owner”串成单写者临界区,
|
|
118
|
+
// 防止两个重启进程同时清理同一把陈旧锁后双写 WAL。
|
|
119
|
+
if (existsSync(this.lockPath)) {
|
|
120
|
+
const owner = readLockOwner(this.lockPath);
|
|
121
|
+
if (owner.status !== "stale") {
|
|
122
|
+
throw new Error(`reliable_outbox_store_locked gatewayHash=${this.gatewayHash}`);
|
|
123
|
+
}
|
|
124
|
+
unlinkSync(this.lockPath);
|
|
125
|
+
}
|
|
126
|
+
let fd;
|
|
127
|
+
try {
|
|
128
|
+
fd = openSync(this.lockPath, "wx", 0o600);
|
|
129
|
+
writeAll(fd, Buffer.from(JSON.stringify({
|
|
130
|
+
v: STORE_VERSION,
|
|
131
|
+
pid: process.pid,
|
|
132
|
+
token: this.lockToken,
|
|
133
|
+
createdAt: Date.now(),
|
|
134
|
+
}), "utf8"));
|
|
135
|
+
fsyncSync(fd);
|
|
136
|
+
setRestrictiveFilePermissions(this.lockPath);
|
|
137
|
+
this.lockFd = fd;
|
|
138
|
+
fd = undefined;
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
if (fd !== undefined) {
|
|
142
|
+
try {
|
|
143
|
+
closeSync(fd);
|
|
144
|
+
}
|
|
145
|
+
catch { /* best effort */ }
|
|
146
|
+
try {
|
|
147
|
+
unlinkSync(this.lockPath);
|
|
148
|
+
}
|
|
149
|
+
catch { /* best effort */ }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
acquireOperationLock() {
|
|
154
|
+
try {
|
|
155
|
+
mkdirSync(this.operationLockPath, { mode: 0o700 });
|
|
156
|
+
setRestrictiveDirPermissions(this.operationLockPath);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (errorCode(error) === "EEXIST") {
|
|
160
|
+
throw new Error("reliable_outbox_store_operation_locked");
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
releaseOperationLock() {
|
|
166
|
+
try {
|
|
167
|
+
rmSync(this.operationLockPath, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
catch { /* fail closed next time */ }
|
|
170
|
+
}
|
|
171
|
+
openNewestGeneration() {
|
|
172
|
+
const candidates = this.generationPaths();
|
|
173
|
+
const parsed = [];
|
|
174
|
+
const errors = [];
|
|
175
|
+
for (const candidate of candidates) {
|
|
176
|
+
try {
|
|
177
|
+
parsed.push(parseGeneration(candidate.path, candidate.generation, this.gatewayHash));
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
errors.push(asError(error));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
parsed.sort((left, right) => right.generation - left.generation);
|
|
184
|
+
const newest = parsed[0];
|
|
185
|
+
if (!newest) {
|
|
186
|
+
if (candidates.length > 0) {
|
|
187
|
+
throw new Error(`reliable_outbox_store_corrupt: ${errors.map((error) => error.message).join("; ")}`);
|
|
188
|
+
}
|
|
189
|
+
this.activateNewGeneration(1, new Map());
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (statSync(newest.path).size !== newest.validBytes) {
|
|
193
|
+
const repairFd = openSync(newest.path, "r+");
|
|
194
|
+
try {
|
|
195
|
+
ftruncateSync(repairFd, newest.validBytes);
|
|
196
|
+
fsyncSync(repairFd);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
closeSync(repairFd);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
this.entries.clear();
|
|
203
|
+
for (const [key, entry] of newest.entries)
|
|
204
|
+
this.entries.set(key, entry);
|
|
205
|
+
this.generation = newest.generation;
|
|
206
|
+
this.journalPath = newest.path;
|
|
207
|
+
this.journalFd = openSync(newest.path, "a");
|
|
208
|
+
this.journalBytes = newest.validBytes;
|
|
209
|
+
this.removeInactiveGenerations(newest.path);
|
|
210
|
+
}
|
|
211
|
+
generationPaths() {
|
|
212
|
+
const pattern = new RegExp(`^${this.gatewayHash}\\.(\\d+)\\.wal$`);
|
|
213
|
+
const result = [];
|
|
214
|
+
for (const name of readdirSync(this.options.directory)) {
|
|
215
|
+
const match = pattern.exec(name);
|
|
216
|
+
if (!match)
|
|
217
|
+
continue;
|
|
218
|
+
const generation = Number(match[1]);
|
|
219
|
+
if (!Number.isSafeInteger(generation) || generation < 1)
|
|
220
|
+
continue;
|
|
221
|
+
result.push({ path: join(this.options.directory, name), generation });
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
append(record) {
|
|
226
|
+
const fd = this.journalFd;
|
|
227
|
+
if (fd === undefined)
|
|
228
|
+
throw new Error("reliable_outbox_store_not_open");
|
|
229
|
+
const bytes = encodeRecord(record);
|
|
230
|
+
const previousBytes = this.journalBytes;
|
|
231
|
+
try {
|
|
232
|
+
writeAll(fd, bytes);
|
|
233
|
+
fsyncSync(fd);
|
|
234
|
+
this.journalBytes += bytes.byteLength;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
// 写入或 fsync 失败时回滚到上一次已提交边界;回滚也失败则永久停用本实例。
|
|
238
|
+
try {
|
|
239
|
+
ftruncateSync(fd, previousBytes);
|
|
240
|
+
fsyncSync(fd);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
this.failed = true;
|
|
244
|
+
}
|
|
245
|
+
throw new Error(`reliable_outbox_store_write_failed: ${asError(error).message}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
compactIfNeeded() {
|
|
249
|
+
const liveBytes = [...this.entries.values()].reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
250
|
+
const threshold = Math.max(COMPACTION_MIN_BYTES, (liveBytes * 2) + COMPACTION_HEADROOM_BYTES);
|
|
251
|
+
if (this.entries.size > 0 && this.journalBytes <= threshold)
|
|
252
|
+
return;
|
|
253
|
+
try {
|
|
254
|
+
// 新代完整持久化后才切换 fd;压缩失败不撤销已经写入旧 WAL 的业务变更。
|
|
255
|
+
this.activateNewGeneration(this.nextGeneration(), this.entries);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
this.reportMaintenanceError(asError(error));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
activateNewGeneration(generation, entries) {
|
|
262
|
+
const path = join(this.options.directory, `${this.filePrefix}${generation}.wal`);
|
|
263
|
+
let fd;
|
|
264
|
+
try {
|
|
265
|
+
fd = openSync(path, "ax", 0o600);
|
|
266
|
+
writeAll(fd, encodeRecord({
|
|
267
|
+
v: STORE_VERSION,
|
|
268
|
+
kind: "header",
|
|
269
|
+
gatewayHash: this.gatewayHash,
|
|
270
|
+
generation,
|
|
271
|
+
}));
|
|
272
|
+
for (const entry of entries.values()) {
|
|
273
|
+
writeAll(fd, encodeRecord({ v: STORE_VERSION, kind: "put", entry }));
|
|
274
|
+
}
|
|
275
|
+
writeAll(fd, encodeRecord({
|
|
276
|
+
v: STORE_VERSION,
|
|
277
|
+
kind: "checkpoint",
|
|
278
|
+
count: entries.size,
|
|
279
|
+
digest: stateDigest(entries),
|
|
280
|
+
}));
|
|
281
|
+
fsyncSync(fd);
|
|
282
|
+
setRestrictiveFilePermissions(path);
|
|
283
|
+
fsyncDirectory(this.options.directory);
|
|
284
|
+
const bytes = fstatSync(fd).size;
|
|
285
|
+
const oldFd = this.journalFd;
|
|
286
|
+
this.journalFd = fd;
|
|
287
|
+
this.journalPath = path;
|
|
288
|
+
this.journalBytes = bytes;
|
|
289
|
+
this.generation = generation;
|
|
290
|
+
fd = undefined;
|
|
291
|
+
if (oldFd !== undefined) {
|
|
292
|
+
try {
|
|
293
|
+
closeSync(oldFd);
|
|
294
|
+
}
|
|
295
|
+
catch { /* the new generation is already durable */ }
|
|
296
|
+
}
|
|
297
|
+
this.removeInactiveGenerations(path);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (fd !== undefined) {
|
|
301
|
+
try {
|
|
302
|
+
closeSync(fd);
|
|
303
|
+
}
|
|
304
|
+
catch { /* best effort */ }
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
rmSync(path, { force: true });
|
|
308
|
+
}
|
|
309
|
+
catch { /* best effort */ }
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
removeInactiveGenerations(activePath) {
|
|
314
|
+
const previous = this.generationPaths()
|
|
315
|
+
.filter((candidate) => candidate.path !== activePath && candidate.generation < this.generation)
|
|
316
|
+
.sort((left, right) => right.generation - left.generation)[0]?.path;
|
|
317
|
+
for (const candidate of this.generationPaths()) {
|
|
318
|
+
if (candidate.path === activePath || candidate.path === previous)
|
|
319
|
+
continue;
|
|
320
|
+
try {
|
|
321
|
+
unlinkSync(candidate.path);
|
|
322
|
+
}
|
|
323
|
+
catch { /* startup can retry */ }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
nextGeneration() {
|
|
327
|
+
return Math.max(this.generation, ...this.generationPaths().map((candidate) => candidate.generation)) + 1;
|
|
328
|
+
}
|
|
329
|
+
assertUsable() {
|
|
330
|
+
if (this.closed)
|
|
331
|
+
throw new Error("reliable_outbox_store_closed");
|
|
332
|
+
if (this.failed)
|
|
333
|
+
throw new Error("reliable_outbox_store_failed");
|
|
334
|
+
}
|
|
335
|
+
reportMaintenanceError(error) {
|
|
336
|
+
if (this.onMaintenanceError) {
|
|
337
|
+
try {
|
|
338
|
+
this.onMaintenanceError(error);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// Observability must not affect a mutation already committed to WAL.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
console.warn(`[relay] reliable outbox compaction deferred: ${error.message}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
export function reliableRelayOutboxStorageDirectory(profile) {
|
|
349
|
+
return join(profileRoot(profile), STORE_DIR_NAME);
|
|
350
|
+
}
|
|
351
|
+
export function clearReliableRelayOutboxStorage(profile) {
|
|
352
|
+
const directory = reliableRelayOutboxStorageDirectory(profile);
|
|
353
|
+
const lockPath = operationLockPath(directory);
|
|
354
|
+
mkdirSync(dirname(directory), { recursive: true, mode: 0o700 });
|
|
355
|
+
try {
|
|
356
|
+
mkdirSync(lockPath, { mode: 0o700 });
|
|
357
|
+
setRestrictiveDirPermissions(lockPath);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
if (errorCode(error) === "EEXIST")
|
|
361
|
+
throw new Error("reliable_outbox_store_operation_locked");
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
if (!existsSync(directory))
|
|
366
|
+
return;
|
|
367
|
+
for (const name of readdirSync(directory)) {
|
|
368
|
+
if (!name.endsWith(".lock"))
|
|
369
|
+
continue;
|
|
370
|
+
const owner = readLockOwner(join(directory, name));
|
|
371
|
+
if (owner.status !== "stale") {
|
|
372
|
+
throw new Error(`reliable_outbox_store_in_use lock=${name}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
rmSync(directory, { recursive: true, force: true });
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
try {
|
|
379
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
380
|
+
}
|
|
381
|
+
catch { /* fail closed */ }
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function parseGeneration(path, generation, gatewayHash) {
|
|
385
|
+
if (statSync(path).size > MAX_JOURNAL_BYTES)
|
|
386
|
+
throw new Error(`journal_too_large generation=${generation}`);
|
|
387
|
+
const file = readFileSync(path);
|
|
388
|
+
const lastNewline = file.lastIndexOf(0x0a);
|
|
389
|
+
if (lastNewline < 0)
|
|
390
|
+
throw new Error(`journal_missing_header generation=${generation}`);
|
|
391
|
+
const validBytes = lastNewline + 1;
|
|
392
|
+
const lines = file.subarray(0, validBytes).toString("utf8").split("\n");
|
|
393
|
+
lines.pop();
|
|
394
|
+
const entries = new Map();
|
|
395
|
+
let checkpointSeen = false;
|
|
396
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
397
|
+
let value;
|
|
398
|
+
try {
|
|
399
|
+
value = JSON.parse(lines[index]);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
throw new Error(`journal_invalid_json generation=${generation} line=${index + 1}`);
|
|
403
|
+
}
|
|
404
|
+
const record = asRecord(value);
|
|
405
|
+
if (!record || record.v !== STORE_VERSION) {
|
|
406
|
+
throw new Error(`journal_invalid_version generation=${generation} line=${index + 1}`);
|
|
407
|
+
}
|
|
408
|
+
if (index === 0) {
|
|
409
|
+
if (record.kind !== "header" || record.gatewayHash !== gatewayHash || record.generation !== generation) {
|
|
410
|
+
throw new Error(`journal_invalid_header generation=${generation}`);
|
|
411
|
+
}
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (record.kind === "put") {
|
|
415
|
+
const entry = parseStoredEntry(record.entry, generation, index + 1);
|
|
416
|
+
if (entries.has(entry.key))
|
|
417
|
+
throw new Error(`journal_duplicate_put generation=${generation} key=${entry.key}`);
|
|
418
|
+
entries.set(entry.key, entry);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (record.kind === "remove") {
|
|
422
|
+
if (!checkpointSeen || typeof record.key !== "string" || !entries.delete(record.key)) {
|
|
423
|
+
throw new Error(`journal_invalid_remove generation=${generation} line=${index + 1}`);
|
|
424
|
+
}
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (record.kind === "checkpoint") {
|
|
428
|
+
if (checkpointSeen || record.count !== entries.size || record.digest !== stateDigest(entries)) {
|
|
429
|
+
throw new Error(`journal_invalid_checkpoint generation=${generation}`);
|
|
430
|
+
}
|
|
431
|
+
checkpointSeen = true;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
throw new Error(`journal_invalid_record generation=${generation} line=${index + 1}`);
|
|
435
|
+
}
|
|
436
|
+
if (!checkpointSeen)
|
|
437
|
+
throw new Error(`journal_incomplete_generation generation=${generation}`);
|
|
438
|
+
return { path, generation, entries, validBytes };
|
|
439
|
+
}
|
|
440
|
+
function parseStoredEntry(value, generation, line) {
|
|
441
|
+
const entry = asRecord(value);
|
|
442
|
+
if (!entry
|
|
443
|
+
|| typeof entry.key !== "string"
|
|
444
|
+
|| typeof entry.byteLength !== "number"
|
|
445
|
+
|| typeof entry.contentHash !== "string"
|
|
446
|
+
|| (entry.mode !== "event_ack" && entry.mode !== "response_ack")
|
|
447
|
+
|| !Object.prototype.hasOwnProperty.call(entry, "message")
|
|
448
|
+
|| (entry.deliveryId !== undefined && typeof entry.deliveryId !== "string")) {
|
|
449
|
+
throw new Error(`journal_invalid_entry generation=${generation} line=${line}`);
|
|
450
|
+
}
|
|
451
|
+
return entry;
|
|
452
|
+
}
|
|
453
|
+
function stateDigest(entries) {
|
|
454
|
+
return createHash("sha256")
|
|
455
|
+
.update(JSON.stringify([...entries.values()].map((entry) => [entry.key, entry.contentHash])))
|
|
456
|
+
.digest("hex");
|
|
457
|
+
}
|
|
458
|
+
function encodeRecord(record) {
|
|
459
|
+
return Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
|
|
460
|
+
}
|
|
461
|
+
function writeAll(fd, bytes) {
|
|
462
|
+
let offset = 0;
|
|
463
|
+
while (offset < bytes.byteLength) {
|
|
464
|
+
const written = writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
465
|
+
if (written <= 0)
|
|
466
|
+
throw new Error("reliable_outbox_store_zero_byte_write");
|
|
467
|
+
offset += written;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function fsyncDirectory(directory) {
|
|
471
|
+
let fd;
|
|
472
|
+
try {
|
|
473
|
+
fd = openSync(directory, "r");
|
|
474
|
+
fsyncSync(fd);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// Windows does not consistently permit directory handles; file fsync is
|
|
478
|
+
// still honored there. POSIX platforms get the directory-entry barrier.
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
if (fd !== undefined) {
|
|
482
|
+
try {
|
|
483
|
+
closeSync(fd);
|
|
484
|
+
}
|
|
485
|
+
catch { /* best effort */ }
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function processIsAlive(pid) {
|
|
490
|
+
try {
|
|
491
|
+
process.kill(pid, 0);
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
return errorCode(error) !== "ESRCH";
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function readLockOwner(path) {
|
|
499
|
+
try {
|
|
500
|
+
const lock = JSON.parse(readFileSync(path, "utf8"));
|
|
501
|
+
if (Number.isSafeInteger(lock.pid) && Number(lock.pid) > 0) {
|
|
502
|
+
return { status: processIsAlive(Number(lock.pid)) ? "active" : "stale" };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// Age check below distinguishes an in-progress write from stale garbage.
|
|
507
|
+
}
|
|
508
|
+
// 无法验证 owner 身份时必须 fail closed,不能猜测为陈旧锁后删除。
|
|
509
|
+
return { status: "unknown" };
|
|
510
|
+
}
|
|
511
|
+
function operationLockPath(directory) {
|
|
512
|
+
return join(dirname(directory), `${basename(directory)}.operation-lock`);
|
|
513
|
+
}
|
|
514
|
+
function errorCode(value) {
|
|
515
|
+
return value && typeof value === "object" && "code" in value
|
|
516
|
+
? String(value.code)
|
|
517
|
+
: undefined;
|
|
518
|
+
}
|
|
519
|
+
function asRecord(value) {
|
|
520
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
521
|
+
? value
|
|
522
|
+
: undefined;
|
|
523
|
+
}
|
|
524
|
+
function asError(value) {
|
|
525
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
526
|
+
}
|
|
527
|
+
//# sourceMappingURL=reliable-relay-outbox-store.js.map
|