@deepmaid/maid-state 0.0.0-stage → 0.1.0-alpha.2
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/lib/index.d.ts +411 -0
- package/lib/index.js +1113 -0
- package/package.json +24 -3
- package/README.md +0 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { createServer, request } from "node:https";
|
|
6
|
+
import { checkServerIdentity } from "node:tls";
|
|
7
|
+
//#region src/protocol.ts
|
|
8
|
+
/**
|
|
9
|
+
* maid-state 协议信封(阶段规格 §6,2026-11-06):传输无关的命令/快照/回执三件套。
|
|
10
|
+
* 稳定性纪律:
|
|
11
|
+
* - turn eventId 派生命名空间 'emotion.turn.v1' 与 commandHash 的语义字段集都**不随
|
|
12
|
+
* 线 protocolVersion 变化**(升级后重放旧命令不得被当成新事件/新冲突);
|
|
13
|
+
* - canonicalJson 键排序用代码点比较,不用 localeCompare(ICU 随构建漂移会破哈希稳定性)。
|
|
14
|
+
*/
|
|
15
|
+
const PROTOCOL_VERSION = 1;
|
|
16
|
+
/** 稳定机器码错误(规格 §11:永久协议错误与临时网络错误必须分开) */
|
|
17
|
+
var StateCommandError = class extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.name = "StateCommandError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** 确定性 JSON 序列化:键代码点排序、剔除 undefined;数字/字符串走 JSON 标准形态 */
|
|
26
|
+
function canonicalJson(value) {
|
|
27
|
+
if (value === void 0) return "null";
|
|
28
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
29
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
30
|
+
return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
|
|
31
|
+
}
|
|
32
|
+
function revisionOf(value) {
|
|
33
|
+
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
/** UUIDv7(时间有序,重试语义友好);eventId 手势/重逢/补偿通道使用 */
|
|
36
|
+
function newEventId(now = Date.now()) {
|
|
37
|
+
const bytes = randomBytes(16);
|
|
38
|
+
const timestamp = BigInt(now);
|
|
39
|
+
for (let i = 0; i < 6; i++) bytes[i] = Number(timestamp >> BigInt((5 - i) * 8) & 255n);
|
|
40
|
+
bytes[6] = bytes[6] & 15 | 112;
|
|
41
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
42
|
+
const hex = bytes.toString("hex");
|
|
43
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
44
|
+
}
|
|
45
|
+
/** turn 命令稳定身份:turnKey 派生(规格 §6.1),宿主会话/轮次重放复用同一 eventId */
|
|
46
|
+
function turnEventId(input) {
|
|
47
|
+
return `turn_${revisionOf([
|
|
48
|
+
"emotion.turn.v1",
|
|
49
|
+
input.hostId,
|
|
50
|
+
input.sessionId,
|
|
51
|
+
input.turn
|
|
52
|
+
])}`;
|
|
53
|
+
}
|
|
54
|
+
/** 语义命令哈希:只覆盖领域语义字段,不含 protocolVersion/传输元数据(规格 §6.1) */
|
|
55
|
+
function commandHash(command) {
|
|
56
|
+
return revisionOf({
|
|
57
|
+
eventId: command.eventId,
|
|
58
|
+
hostId: command.hostId,
|
|
59
|
+
maidId: command.maidId,
|
|
60
|
+
kind: command.kind,
|
|
61
|
+
occurredAt: command.occurredAt,
|
|
62
|
+
sourceConfigRevision: command.sourceConfigRevision,
|
|
63
|
+
payload: command.payload
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** 入站命令形状守卫(规格 §6.1):协议版本/必填串/有限时间/payload 对象 */
|
|
67
|
+
function assertStateCommand(value) {
|
|
68
|
+
const c = value;
|
|
69
|
+
if (c?.protocolVersion !== 1) throw new StateCommandError("PROTOCOL_VERSION", `protocolVersion unsupported (expected 1, got ${String(c?.protocolVersion)})`);
|
|
70
|
+
for (const key of [
|
|
71
|
+
"eventId",
|
|
72
|
+
"hostId",
|
|
73
|
+
"maidId",
|
|
74
|
+
"kind",
|
|
75
|
+
"sourceConfigRevision"
|
|
76
|
+
]) {
|
|
77
|
+
const v = c[key];
|
|
78
|
+
if (typeof v !== "string" || v.length === 0) throw new StateCommandError("COMMAND_SHAPE", `${key} required (non-empty string)`);
|
|
79
|
+
}
|
|
80
|
+
if (!Number.isFinite(c.occurredAt)) throw new StateCommandError("COMMAND_SHAPE", "occurredAt must be a finite number");
|
|
81
|
+
if (c.payload === void 0 || c.payload === null || typeof c.payload !== "object") throw new StateCommandError("COMMAND_SHAPE", "payload object required");
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/lock.ts
|
|
85
|
+
const HEARTBEAT_INTERVAL_MS = 5e3;
|
|
86
|
+
function pidAlive(pid) {
|
|
87
|
+
try {
|
|
88
|
+
process.kill(pid, 0);
|
|
89
|
+
return true;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return error.code !== "ESRCH";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function acquireDataDirLock(dataDir) {
|
|
95
|
+
mkdirSync(dataDir, { recursive: true });
|
|
96
|
+
try {
|
|
97
|
+
chmodSync(dataDir, 448);
|
|
98
|
+
} catch {}
|
|
99
|
+
const file = join(dataDir, "server.lock");
|
|
100
|
+
const info = {
|
|
101
|
+
pid: process.pid,
|
|
102
|
+
startedAt: Date.now(),
|
|
103
|
+
heartbeatAt: Date.now()
|
|
104
|
+
};
|
|
105
|
+
const writeBody = (flag) => {
|
|
106
|
+
const fd = openSync(file, flag, 384);
|
|
107
|
+
try {
|
|
108
|
+
writeFileSync(fd, JSON.stringify(info));
|
|
109
|
+
} finally {
|
|
110
|
+
closeSync(fd);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
info.heartbeatAt = info.startedAt;
|
|
114
|
+
try {
|
|
115
|
+
writeBody("wx");
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error.code !== "EEXIST") throw new Error(`maid-state: cannot create data dir lock at ${file} (${error.message})`, { cause: error });
|
|
118
|
+
let holder = {};
|
|
119
|
+
try {
|
|
120
|
+
holder = JSON.parse(readFileSync(file, "utf8"));
|
|
121
|
+
} catch {}
|
|
122
|
+
if (typeof holder.pid === "number" && pidAlive(holder.pid)) throw new Error(`maid-state: data directory locked by active server (pid ${holder.pid}): ${dataDir}`, { cause: error });
|
|
123
|
+
try {
|
|
124
|
+
unlinkSync(file);
|
|
125
|
+
} catch {}
|
|
126
|
+
writeBody("wx");
|
|
127
|
+
}
|
|
128
|
+
const timer = setInterval(() => {
|
|
129
|
+
info.heartbeatAt = Date.now();
|
|
130
|
+
try {
|
|
131
|
+
writeBody("w");
|
|
132
|
+
} catch {}
|
|
133
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
134
|
+
timer.unref();
|
|
135
|
+
let released = false;
|
|
136
|
+
return {
|
|
137
|
+
pid: info.pid,
|
|
138
|
+
release() {
|
|
139
|
+
if (released) return;
|
|
140
|
+
released = true;
|
|
141
|
+
clearInterval(timer);
|
|
142
|
+
try {
|
|
143
|
+
unlinkSync(file);
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/authority-store.ts
|
|
150
|
+
const SCHEMA$1 = `
|
|
151
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
152
|
+
CREATE TABLE IF NOT EXISTS maid_state (
|
|
153
|
+
maid_id TEXT PRIMARY KEY,
|
|
154
|
+
revision INTEGER NOT NULL,
|
|
155
|
+
state_json TEXT NOT NULL
|
|
156
|
+
);
|
|
157
|
+
CREATE TABLE IF NOT EXISTS event_receipt (
|
|
158
|
+
maid_id TEXT NOT NULL,
|
|
159
|
+
event_id TEXT NOT NULL,
|
|
160
|
+
command_hash TEXT NOT NULL,
|
|
161
|
+
host_id TEXT NOT NULL,
|
|
162
|
+
kind TEXT NOT NULL,
|
|
163
|
+
occurred_at INTEGER NOT NULL,
|
|
164
|
+
received_at INTEGER NOT NULL,
|
|
165
|
+
effective_at INTEGER,
|
|
166
|
+
source_config_revision TEXT NOT NULL,
|
|
167
|
+
applied_config_revision TEXT NOT NULL,
|
|
168
|
+
status TEXT NOT NULL,
|
|
169
|
+
applied_revision INTEGER,
|
|
170
|
+
error_code TEXT,
|
|
171
|
+
audit_json TEXT NOT NULL,
|
|
172
|
+
PRIMARY KEY (maid_id, event_id)
|
|
173
|
+
);
|
|
174
|
+
`;
|
|
175
|
+
function rowToReceipt(row) {
|
|
176
|
+
return {
|
|
177
|
+
maidId: row.maid_id,
|
|
178
|
+
eventId: row.event_id,
|
|
179
|
+
commandHash: row.command_hash,
|
|
180
|
+
hostId: row.host_id,
|
|
181
|
+
kind: row.kind,
|
|
182
|
+
occurredAt: row.occurred_at,
|
|
183
|
+
receivedAt: row.received_at,
|
|
184
|
+
effectiveAt: row.effective_at,
|
|
185
|
+
sourceConfigRevision: row.source_config_revision,
|
|
186
|
+
appliedConfigRevision: row.applied_config_revision,
|
|
187
|
+
status: row.status,
|
|
188
|
+
appliedRevision: row.applied_revision,
|
|
189
|
+
errorCode: row.error_code,
|
|
190
|
+
audit: JSON.parse(row.audit_json)
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function openAuthorityStore(options) {
|
|
194
|
+
const readOnly = options.readOnly === true;
|
|
195
|
+
const dbFile = join(options.dataDir, "authority.db");
|
|
196
|
+
let lock;
|
|
197
|
+
if (!readOnly) lock = acquireDataDirLock(options.dataDir);
|
|
198
|
+
const db = new DatabaseSync(dbFile, readOnly ? { readOnly: true } : { readOnly: false });
|
|
199
|
+
if (!readOnly) {
|
|
200
|
+
try {
|
|
201
|
+
chmodSync(dbFile, 384);
|
|
202
|
+
} catch {}
|
|
203
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
204
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
205
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
206
|
+
db.exec(SCHEMA$1);
|
|
207
|
+
}
|
|
208
|
+
const metaGet = (key) => {
|
|
209
|
+
return db.prepare("SELECT value FROM meta WHERE key = ?").get(key)?.value;
|
|
210
|
+
};
|
|
211
|
+
let authorityId = metaGet("authorityId");
|
|
212
|
+
if (readOnly) {
|
|
213
|
+
if (authorityId === void 0) throw new Error(`maid-state: not a maid-state database (missing authorityId): ${dbFile}`);
|
|
214
|
+
} else if (authorityId === void 0) {
|
|
215
|
+
authorityId = randomUUID();
|
|
216
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("authorityId", authorityId);
|
|
217
|
+
}
|
|
218
|
+
let txDepth = 0;
|
|
219
|
+
const requireTx = (op) => {
|
|
220
|
+
if (txDepth === 0) throw new Error(`maid-state: ${op} requires an active transaction (call store.transaction(...))`);
|
|
221
|
+
};
|
|
222
|
+
const readState = (maidId) => {
|
|
223
|
+
const row = db.prepare("SELECT revision, state_json FROM maid_state WHERE maid_id = ?").get(maidId);
|
|
224
|
+
return row === void 0 ? void 0 : {
|
|
225
|
+
state: JSON.parse(row.state_json),
|
|
226
|
+
revision: row.revision
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
const writeState = (maidId, value) => {
|
|
230
|
+
requireTx("writeState");
|
|
231
|
+
db.prepare("INSERT OR REPLACE INTO maid_state (maid_id, revision, state_json) VALUES (?, ?, ?)").run(maidId, value.revision, JSON.stringify(value.state));
|
|
232
|
+
};
|
|
233
|
+
const readReceipt = (maidId, eventId) => {
|
|
234
|
+
const row = db.prepare("SELECT * FROM event_receipt WHERE maid_id = ? AND event_id = ?").get(maidId, eventId);
|
|
235
|
+
return row === void 0 ? void 0 : rowToReceipt(row);
|
|
236
|
+
};
|
|
237
|
+
const writeReceipt = (receipt) => {
|
|
238
|
+
requireTx("writeReceipt");
|
|
239
|
+
db.prepare(`INSERT OR REPLACE INTO event_receipt (
|
|
240
|
+
maid_id, event_id, command_hash, host_id, kind, occurred_at, received_at, effective_at,
|
|
241
|
+
source_config_revision, applied_config_revision, status, applied_revision, error_code, audit_json
|
|
242
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(receipt.maidId, receipt.eventId, receipt.commandHash, receipt.hostId, receipt.kind, receipt.occurredAt, receipt.receivedAt, receipt.effectiveAt, receipt.sourceConfigRevision, receipt.appliedConfigRevision, receipt.status, receipt.appliedRevision, receipt.errorCode, JSON.stringify(receipt.audit));
|
|
243
|
+
};
|
|
244
|
+
return {
|
|
245
|
+
get authorityId() {
|
|
246
|
+
return authorityId;
|
|
247
|
+
},
|
|
248
|
+
readState,
|
|
249
|
+
writeState,
|
|
250
|
+
readReceipt,
|
|
251
|
+
writeReceipt,
|
|
252
|
+
transaction(fn) {
|
|
253
|
+
if (readOnly) throw new Error("maid-state: readOnly store cannot open transactions");
|
|
254
|
+
if (txDepth > 0) throw new Error("maid-state: nested transactions are not allowed");
|
|
255
|
+
db.exec("BEGIN IMMEDIATE");
|
|
256
|
+
txDepth = 1;
|
|
257
|
+
try {
|
|
258
|
+
const out = fn({
|
|
259
|
+
readState,
|
|
260
|
+
writeState,
|
|
261
|
+
readReceipt,
|
|
262
|
+
writeReceipt
|
|
263
|
+
});
|
|
264
|
+
db.exec("COMMIT");
|
|
265
|
+
return out;
|
|
266
|
+
} catch (error) {
|
|
267
|
+
txDepth = 0;
|
|
268
|
+
try {
|
|
269
|
+
db.exec("ROLLBACK");
|
|
270
|
+
} catch {}
|
|
271
|
+
throw error;
|
|
272
|
+
} finally {
|
|
273
|
+
txDepth = 0;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
listEvents(filter) {
|
|
277
|
+
const clauses = [];
|
|
278
|
+
const params = [];
|
|
279
|
+
if (filter.maidId !== void 0) {
|
|
280
|
+
clauses.push("maid_id = ?");
|
|
281
|
+
params.push(filter.maidId);
|
|
282
|
+
}
|
|
283
|
+
if (filter.afterRevision !== void 0) {
|
|
284
|
+
clauses.push("(applied_revision IS NULL OR applied_revision > ?)");
|
|
285
|
+
params.push(filter.afterRevision);
|
|
286
|
+
}
|
|
287
|
+
const limit = Math.max(1, Math.min(500, Math.floor(filter.limit)));
|
|
288
|
+
const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
289
|
+
return db.prepare(`SELECT * FROM event_receipt${where} ORDER BY rowid LIMIT ?`).all(...params, limit).map(rowToReceipt);
|
|
290
|
+
},
|
|
291
|
+
exportStates() {
|
|
292
|
+
const rows = db.prepare("SELECT maid_id, revision, state_json FROM maid_state").all();
|
|
293
|
+
const out = {};
|
|
294
|
+
for (const row of rows) out[row.maid_id] = {
|
|
295
|
+
state: JSON.parse(row.state_json),
|
|
296
|
+
revision: row.revision
|
|
297
|
+
};
|
|
298
|
+
return out;
|
|
299
|
+
},
|
|
300
|
+
close() {
|
|
301
|
+
db.close();
|
|
302
|
+
lock?.release();
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/authority.ts
|
|
308
|
+
var StateAuthority = class {
|
|
309
|
+
store;
|
|
310
|
+
domain;
|
|
311
|
+
now;
|
|
312
|
+
authorityId;
|
|
313
|
+
#chains = /* @__PURE__ */ new Map();
|
|
314
|
+
constructor(store, domain, now) {
|
|
315
|
+
this.store = store;
|
|
316
|
+
this.domain = domain;
|
|
317
|
+
this.now = now;
|
|
318
|
+
this.authorityId = store.authorityId;
|
|
319
|
+
}
|
|
320
|
+
/** 当前权威快照;fresh 状态不落库、不推进 revision */
|
|
321
|
+
snapshot(maidId) {
|
|
322
|
+
const entry = this.domain.config(maidId);
|
|
323
|
+
if (entry === void 0) throw new StateCommandError("UNKNOWN_MAID", `maid '${maidId}' has no config`);
|
|
324
|
+
const stored = this.store.readState(maidId);
|
|
325
|
+
return {
|
|
326
|
+
maidId,
|
|
327
|
+
stateRevision: stored?.revision ?? 0,
|
|
328
|
+
configRevision: entry.configRevision,
|
|
329
|
+
serverTime: this.now(),
|
|
330
|
+
state: stored?.state ?? this.domain.fresh(maidId, entry.config),
|
|
331
|
+
config: entry.config
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
submit(command) {
|
|
335
|
+
const next = (this.#chains.get(command.maidId) ?? Promise.resolve()).then(() => this.submitNow(command), () => this.submitNow(command));
|
|
336
|
+
this.#chains.set(command.maidId, next.catch(() => {}));
|
|
337
|
+
return next;
|
|
338
|
+
}
|
|
339
|
+
submitNow(command) {
|
|
340
|
+
try {
|
|
341
|
+
assertStateCommand(command);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
return this.reject(command, error, "shape 校验失败(无法持久化回执时仅返回)");
|
|
344
|
+
}
|
|
345
|
+
const hash = commandHash(command);
|
|
346
|
+
const receivedAt = this.now();
|
|
347
|
+
return this.store.transaction((tx) => {
|
|
348
|
+
const prior = tx.readReceipt(command.maidId, command.eventId);
|
|
349
|
+
if (prior !== void 0) {
|
|
350
|
+
if (prior.commandHash === hash) return {
|
|
351
|
+
eventId: command.eventId,
|
|
352
|
+
status: prior.status === "applied" ? "duplicate" : "rejected",
|
|
353
|
+
appliedRevision: prior.appliedRevision,
|
|
354
|
+
snapshot: this.snapshotInTx(tx, command.maidId),
|
|
355
|
+
errorCode: prior.errorCode
|
|
356
|
+
};
|
|
357
|
+
return this.reject(command, new StateCommandError("EVENT_ID_CONFLICT", `eventId '${command.eventId}' reused with different content`), "conflict", tx);
|
|
358
|
+
}
|
|
359
|
+
const entry = this.domain.config(command.maidId);
|
|
360
|
+
if (entry === void 0) return this.reject(command, new StateCommandError("UNKNOWN_MAID", `maid '${command.maidId}' has no config`), "unknown maid", tx);
|
|
361
|
+
let applied;
|
|
362
|
+
try {
|
|
363
|
+
const stored = tx.readState(command.maidId);
|
|
364
|
+
applied = this.domain.apply({
|
|
365
|
+
state: stored?.state ?? this.domain.fresh(command.maidId, entry.config),
|
|
366
|
+
command,
|
|
367
|
+
config: entry.config,
|
|
368
|
+
receivedAt
|
|
369
|
+
});
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error instanceof StateCommandError) return this.reject(command, error, "domain reject", tx);
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
const revision = (tx.readState(command.maidId)?.revision ?? 0) + 1;
|
|
375
|
+
tx.writeState(command.maidId, {
|
|
376
|
+
state: applied.state,
|
|
377
|
+
revision
|
|
378
|
+
});
|
|
379
|
+
tx.writeReceipt({
|
|
380
|
+
maidId: command.maidId,
|
|
381
|
+
eventId: command.eventId,
|
|
382
|
+
commandHash: hash,
|
|
383
|
+
hostId: command.hostId,
|
|
384
|
+
kind: command.kind,
|
|
385
|
+
occurredAt: command.occurredAt,
|
|
386
|
+
receivedAt,
|
|
387
|
+
effectiveAt: applied.effectiveAt,
|
|
388
|
+
sourceConfigRevision: command.sourceConfigRevision,
|
|
389
|
+
appliedConfigRevision: entry.configRevision,
|
|
390
|
+
status: "applied",
|
|
391
|
+
appliedRevision: revision,
|
|
392
|
+
errorCode: null,
|
|
393
|
+
audit: applied.audit
|
|
394
|
+
});
|
|
395
|
+
return {
|
|
396
|
+
eventId: command.eventId,
|
|
397
|
+
status: "applied",
|
|
398
|
+
appliedRevision: revision,
|
|
399
|
+
snapshot: this.snapshotInTx(tx, command.maidId),
|
|
400
|
+
errorCode: null
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
/** 拒绝回执:maidId 可用时持久化(幂等复述),不可用时(坏形状)仅返回 */
|
|
405
|
+
reject(command, error, reason, tx) {
|
|
406
|
+
const code = error instanceof StateCommandError ? error.code : "INTERNAL";
|
|
407
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
408
|
+
const receipt = {
|
|
409
|
+
maidId: command.maidId,
|
|
410
|
+
eventId: command.eventId,
|
|
411
|
+
commandHash: safeHash(command),
|
|
412
|
+
hostId: command.hostId,
|
|
413
|
+
kind: command.kind,
|
|
414
|
+
occurredAt: command.occurredAt,
|
|
415
|
+
receivedAt: this.now(),
|
|
416
|
+
effectiveAt: null,
|
|
417
|
+
sourceConfigRevision: command.sourceConfigRevision,
|
|
418
|
+
appliedConfigRevision: "",
|
|
419
|
+
status: "rejected",
|
|
420
|
+
appliedRevision: null,
|
|
421
|
+
errorCode: code,
|
|
422
|
+
audit: {
|
|
423
|
+
reason,
|
|
424
|
+
message
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
const persist = (writer) => {
|
|
428
|
+
if (typeof command.maidId === "string" && command.maidId.length > 0 && typeof command.eventId === "string" && command.eventId.length > 0) writer.writeReceipt(receipt);
|
|
429
|
+
};
|
|
430
|
+
if (tx !== void 0) {
|
|
431
|
+
persist(tx);
|
|
432
|
+
let snapshot = null;
|
|
433
|
+
try {
|
|
434
|
+
snapshot = this.snapshotInTx(tx, command.maidId);
|
|
435
|
+
} catch {}
|
|
436
|
+
return {
|
|
437
|
+
eventId: command.eventId,
|
|
438
|
+
status: "rejected",
|
|
439
|
+
appliedRevision: null,
|
|
440
|
+
snapshot,
|
|
441
|
+
errorCode: code
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (typeof command.maidId === "string" && command.maidId.length > 0) try {
|
|
445
|
+
this.store.transaction((writer) => persist(writer));
|
|
446
|
+
let snapshot = null;
|
|
447
|
+
try {
|
|
448
|
+
snapshot = this.snapshot(command.maidId);
|
|
449
|
+
} catch {}
|
|
450
|
+
return {
|
|
451
|
+
eventId: command.eventId,
|
|
452
|
+
status: "rejected",
|
|
453
|
+
appliedRevision: null,
|
|
454
|
+
snapshot,
|
|
455
|
+
errorCode: code
|
|
456
|
+
};
|
|
457
|
+
} catch {}
|
|
458
|
+
return {
|
|
459
|
+
eventId: command.eventId,
|
|
460
|
+
status: "rejected",
|
|
461
|
+
appliedRevision: null,
|
|
462
|
+
snapshot: null,
|
|
463
|
+
errorCode: code
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/** 事务内快照(与公开 snapshot 同语义,读未提交的本事务写入) */
|
|
467
|
+
snapshotInTx(tx, maidId) {
|
|
468
|
+
const entry = this.domain.config(maidId);
|
|
469
|
+
if (entry === void 0) throw new StateCommandError("UNKNOWN_MAID", `maid '${maidId}' has no config`);
|
|
470
|
+
const stored = tx.readState(maidId);
|
|
471
|
+
return {
|
|
472
|
+
maidId,
|
|
473
|
+
stateRevision: stored?.revision ?? 0,
|
|
474
|
+
configRevision: entry.configRevision,
|
|
475
|
+
serverTime: this.now(),
|
|
476
|
+
state: stored?.state ?? this.domain.fresh(maidId, entry.config),
|
|
477
|
+
config: entry.config
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
function safeHash(command) {
|
|
482
|
+
try {
|
|
483
|
+
return commandHash(command);
|
|
484
|
+
} catch {
|
|
485
|
+
return "unhashable";
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/tls.ts
|
|
490
|
+
function normalizeFingerprint(fp) {
|
|
491
|
+
return fp.replaceAll(":", "").trim().toUpperCase();
|
|
492
|
+
}
|
|
493
|
+
function loadKeyCertCa(input) {
|
|
494
|
+
return {
|
|
495
|
+
key: readFileSync(input.keyFile, "utf8"),
|
|
496
|
+
cert: readFileSync(input.certFile, "utf8"),
|
|
497
|
+
ca: readFileSync(input.caFile, "utf8")
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
/** 证书指纹 → host 身份映射(未登记指纹返回 undefined) */
|
|
501
|
+
function identityOfPeer(clients, peerFingerprint256) {
|
|
502
|
+
if (peerFingerprint256 === void 0) return void 0;
|
|
503
|
+
const normalized = normalizeFingerprint(peerFingerprint256);
|
|
504
|
+
return clients.find((c) => normalizeFingerprint(c.fingerprint256) === normalized);
|
|
505
|
+
}
|
|
506
|
+
function maidAllowed(identity, maidId) {
|
|
507
|
+
return identity.allowMaids === void 0 || identity.allowMaids.includes(maidId);
|
|
508
|
+
}
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/http-server.ts
|
|
511
|
+
/**
|
|
512
|
+
* mTLS HTTP 传输·server 面(maid-state 阶段规格 §6.4 信封/§10.3 授权):
|
|
513
|
+
* - 成功响应 { authorityId, payload },错误响应 { authorityId, error: { code, message } };
|
|
514
|
+
* - hostId 只信证书指纹映射,body 自报 hostId 与证书不一致 → 403;
|
|
515
|
+
* - body 上限 2 MiB(413);坏 JSON 400;domain 拒绝 = 200+rejected 回执(稳定码);
|
|
516
|
+
* 未知内部异常 = 500 TEMPORARY(client 可重试);
|
|
517
|
+
* - 不回显请求 payload;本层不 console,日志由调用方注入。
|
|
518
|
+
*/
|
|
519
|
+
const MAX_BODY_BYTES = 2097152;
|
|
520
|
+
function startStateHttpServer(input) {
|
|
521
|
+
const { authority, store } = input;
|
|
522
|
+
const material = loadKeyCertCa(input.tls);
|
|
523
|
+
const server = createServer({
|
|
524
|
+
key: material.key,
|
|
525
|
+
cert: material.cert,
|
|
526
|
+
ca: material.ca,
|
|
527
|
+
requestCert: true,
|
|
528
|
+
rejectUnauthorized: true,
|
|
529
|
+
minVersion: "TLSv1.2"
|
|
530
|
+
});
|
|
531
|
+
const send = (res, status, body) => {
|
|
532
|
+
const payload = JSON.stringify(body);
|
|
533
|
+
res.writeHead(status, {
|
|
534
|
+
"content-type": "application/json",
|
|
535
|
+
"content-length": Buffer.byteLength(payload)
|
|
536
|
+
});
|
|
537
|
+
res.end(payload);
|
|
538
|
+
};
|
|
539
|
+
const sendError = (res, status, code, message) => {
|
|
540
|
+
send(res, status, {
|
|
541
|
+
authorityId: authority.authorityId,
|
|
542
|
+
error: {
|
|
543
|
+
code,
|
|
544
|
+
message
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
};
|
|
548
|
+
server.on("request", (req, res) => {
|
|
549
|
+
const peer = req.socket;
|
|
550
|
+
const identity = identityOfPeer(input.tls.clients, peer.getPeerCertificate?.()?.fingerprint256);
|
|
551
|
+
if (req.url === "/v1/health") {
|
|
552
|
+
send(res, 200, {
|
|
553
|
+
authorityId: authority.authorityId,
|
|
554
|
+
payload: {
|
|
555
|
+
authorityId: authority.authorityId,
|
|
556
|
+
protocolVersion: 1
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (identity === void 0) {
|
|
562
|
+
sendError(res, 403, "IDENTITY_UNKNOWN", "client certificate is not registered");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (req.url === "/v1/status" && req.method === "GET") {
|
|
566
|
+
const states = store.exportStates();
|
|
567
|
+
send(res, 200, {
|
|
568
|
+
authorityId: authority.authorityId,
|
|
569
|
+
payload: {
|
|
570
|
+
authorityId: authority.authorityId,
|
|
571
|
+
protocolVersion: 1,
|
|
572
|
+
maids: Object.entries(states).map(([maidId, s]) => ({
|
|
573
|
+
maidId,
|
|
574
|
+
stateRevision: s.revision
|
|
575
|
+
}))
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if ((req.url ?? "").startsWith("/v1/events") && req.method === "GET") {
|
|
581
|
+
const query = new URL(req.url ?? "/v1/events", "https://localhost").searchParams;
|
|
582
|
+
const limitRaw = query.get("limit");
|
|
583
|
+
const afterRaw = query.get("afterRevision");
|
|
584
|
+
const limit = limitRaw === null ? 100 : Number(limitRaw);
|
|
585
|
+
const afterRevision = afterRaw === null ? void 0 : Number(afterRaw);
|
|
586
|
+
if (!Number.isFinite(limit) || afterRevision !== void 0 && !Number.isFinite(afterRevision)) {
|
|
587
|
+
sendError(res, 400, "BAD_QUERY", "afterRevision/limit query invalid");
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const filter = {
|
|
591
|
+
...query.get("maidId") !== null ? { maidId: query.get("maidId") } : {},
|
|
592
|
+
...afterRevision !== void 0 ? { afterRevision } : {},
|
|
593
|
+
limit
|
|
594
|
+
};
|
|
595
|
+
const events = store.listEvents(filter);
|
|
596
|
+
send(res, 200, {
|
|
597
|
+
authorityId: authority.authorityId,
|
|
598
|
+
payload: { events }
|
|
599
|
+
});
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const snapshotMatch = /^\/v1\/maids\/([^/]+)\/snapshot$/.exec(req.url ?? "");
|
|
603
|
+
if (snapshotMatch !== null && req.method === "GET") {
|
|
604
|
+
const maidId = decodeURIComponent(snapshotMatch[1]);
|
|
605
|
+
if (!maidAllowed(identity, maidId)) {
|
|
606
|
+
sendError(res, 403, "FORBIDDEN", `maid '${maidId}' not allowed for host '${identity.hostId}'`);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
let snapshot;
|
|
610
|
+
try {
|
|
611
|
+
snapshot = authority.snapshot(maidId);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
sendError(res, 404, "UNKNOWN_MAID", error.message);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
send(res, 200, {
|
|
617
|
+
authorityId: authority.authorityId,
|
|
618
|
+
payload: { snapshot }
|
|
619
|
+
});
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const commandsMatch = /^\/v1\/maids\/([^/]+)\/commands$/.exec(req.url ?? "");
|
|
623
|
+
if (commandsMatch !== null && req.method === "POST") {
|
|
624
|
+
const maidId = decodeURIComponent(commandsMatch[1]);
|
|
625
|
+
if (!maidAllowed(identity, maidId)) {
|
|
626
|
+
sendError(res, 403, "FORBIDDEN", `maid '${maidId}' not allowed for host '${identity.hostId}'`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const chunks = [];
|
|
630
|
+
let size = 0;
|
|
631
|
+
let aborted = false;
|
|
632
|
+
req.on("data", (chunk) => {
|
|
633
|
+
if (aborted) return;
|
|
634
|
+
size += chunk.length;
|
|
635
|
+
if (size > MAX_BODY_BYTES) {
|
|
636
|
+
aborted = true;
|
|
637
|
+
sendError(res, 413, "BODY_TOO_LARGE", `body exceeds ${MAX_BODY_BYTES} bytes`);
|
|
638
|
+
req.resume();
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
chunks.push(chunk);
|
|
642
|
+
});
|
|
643
|
+
req.on("end", () => {
|
|
644
|
+
if (aborted) return;
|
|
645
|
+
let command;
|
|
646
|
+
try {
|
|
647
|
+
command = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
648
|
+
} catch {
|
|
649
|
+
sendError(res, 400, "BAD_JSON", "request body is not valid JSON");
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const claimed = command?.hostId;
|
|
653
|
+
if (claimed !== identity.hostId) {
|
|
654
|
+
sendError(res, 403, "FORBIDDEN", `body hostId '${String(claimed)}' does not match certificate identity '${identity.hostId}'`);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (command?.maidId !== maidId) {
|
|
658
|
+
sendError(res, 400, "MAID_MISMATCH", "path maidId does not match command maidId");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
authority.submit(command).then((receipt) => send(res, 200, {
|
|
662
|
+
authorityId: authority.authorityId,
|
|
663
|
+
payload: { receipt }
|
|
664
|
+
}), () => sendError(res, 500, "TEMPORARY", "command application failed; safe to retry"));
|
|
665
|
+
});
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
sendError(res, 404, "NOT_FOUND", `no route for ${req.method} ${req.url}`);
|
|
669
|
+
});
|
|
670
|
+
return new Promise((resolve, reject) => {
|
|
671
|
+
server.once("error", reject);
|
|
672
|
+
server.listen(input.listen.port, input.listen.host, () => {
|
|
673
|
+
const address = server.address();
|
|
674
|
+
const port = typeof address === "object" && address !== null ? address.port : input.listen.port;
|
|
675
|
+
resolve({
|
|
676
|
+
url: `https://${input.listen.host}:${port}`,
|
|
677
|
+
authorityId: authority.authorityId,
|
|
678
|
+
close: () => new Promise((done) => server.close(() => done()))
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region src/http-client.ts
|
|
685
|
+
var StateHttpClient = class {
|
|
686
|
+
#config;
|
|
687
|
+
#material;
|
|
688
|
+
#closed = false;
|
|
689
|
+
constructor(config) {
|
|
690
|
+
this.#config = {
|
|
691
|
+
requestTimeoutMs: 5e3,
|
|
692
|
+
...config
|
|
693
|
+
};
|
|
694
|
+
this.#material = loadKeyCertCa(config);
|
|
695
|
+
}
|
|
696
|
+
async getSnapshot(maidId) {
|
|
697
|
+
return (await this.#call("GET", `/v1/maids/${encodeURIComponent(maidId)}/snapshot`)).snapshot;
|
|
698
|
+
}
|
|
699
|
+
async submit(command) {
|
|
700
|
+
return (await this.#call("POST", `/v1/maids/${encodeURIComponent(command.maidId)}/commands`, command)).receipt;
|
|
701
|
+
}
|
|
702
|
+
async health() {
|
|
703
|
+
return await this.#call("GET", "/v1/health");
|
|
704
|
+
}
|
|
705
|
+
async status() {
|
|
706
|
+
return await this.#call("GET", "/v1/status");
|
|
707
|
+
}
|
|
708
|
+
async listEvents(filter) {
|
|
709
|
+
const params = new URLSearchParams({ limit: String(filter.limit) });
|
|
710
|
+
if (filter.maidId !== void 0) params.set("maidId", filter.maidId);
|
|
711
|
+
if (filter.afterRevision !== void 0) params.set("afterRevision", String(filter.afterRevision));
|
|
712
|
+
return (await this.#call("GET", `/v1/events?${params.toString()}`)).events;
|
|
713
|
+
}
|
|
714
|
+
close() {
|
|
715
|
+
this.#closed = true;
|
|
716
|
+
}
|
|
717
|
+
#call(method, path, body) {
|
|
718
|
+
if (this.#closed) return Promise.reject(/* @__PURE__ */ new Error("maid-state client closed"));
|
|
719
|
+
const payload = body === void 0 ? void 0 : JSON.stringify(body);
|
|
720
|
+
const url = new URL(path, this.#config.url);
|
|
721
|
+
return new Promise((resolve, reject) => {
|
|
722
|
+
const options = {
|
|
723
|
+
method,
|
|
724
|
+
hostname: url.hostname,
|
|
725
|
+
port: url.port,
|
|
726
|
+
path: `${url.pathname}${url.search}`,
|
|
727
|
+
key: this.#material.key,
|
|
728
|
+
cert: this.#material.cert,
|
|
729
|
+
ca: this.#material.ca,
|
|
730
|
+
timeout: this.#config.requestTimeoutMs,
|
|
731
|
+
headers: { ...payload !== void 0 ? {
|
|
732
|
+
"content-type": "application/json",
|
|
733
|
+
"content-length": Buffer.byteLength(payload)
|
|
734
|
+
} : {} },
|
|
735
|
+
checkServerIdentity: (host, cert) => {
|
|
736
|
+
const hostnameError = checkServerIdentity(host, cert);
|
|
737
|
+
if (hostnameError !== void 0) return hostnameError;
|
|
738
|
+
if (cert.fingerprint256 === void 0 || normalizeFingerprint(cert.fingerprint256) !== normalizeFingerprint(this.#config.serverFingerprint256)) return /* @__PURE__ */ new Error(`server fingerprint mismatch (expected ${this.#config.serverFingerprint256})`);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
const req = request(options, (res) => {
|
|
742
|
+
const chunks = [];
|
|
743
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
744
|
+
res.on("end", () => {
|
|
745
|
+
const status = res.statusCode ?? 0;
|
|
746
|
+
let envelope;
|
|
747
|
+
try {
|
|
748
|
+
envelope = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
749
|
+
} catch {
|
|
750
|
+
reject(/* @__PURE__ */ new Error(`maid-state: non-JSON response (status ${status})`));
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (envelope?.authorityId !== void 0 && envelope.authorityId !== this.#config.authorityId) {
|
|
754
|
+
reject(new StateCommandError("AUTHORITY_MISMATCH", `expected authority ${this.#config.authorityId}, got ${envelope.authorityId}`));
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (envelope?.error !== void 0) {
|
|
758
|
+
reject(new StateCommandError(envelope.error.code, envelope.error.message));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (status < 200 || status >= 300 || envelope?.payload === void 0) {
|
|
762
|
+
reject(/* @__PURE__ */ new Error(`maid-state: unexpected response (status ${status})`));
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
resolve(envelope.payload);
|
|
766
|
+
});
|
|
767
|
+
res.on("error", (err) => reject(err));
|
|
768
|
+
});
|
|
769
|
+
req.on("error", (err) => reject(err));
|
|
770
|
+
req.on("timeout", () => {
|
|
771
|
+
req.destroy();
|
|
772
|
+
reject(/* @__PURE__ */ new Error(`maid-state: request timeout after ${this.#config.requestTimeoutMs}ms`));
|
|
773
|
+
});
|
|
774
|
+
if (payload !== void 0) req.write(payload);
|
|
775
|
+
req.end();
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
//#endregion
|
|
780
|
+
//#region src/client-journal.ts
|
|
781
|
+
const DEFAULT_MAX_PENDING = 1e3;
|
|
782
|
+
const SCHEMA = `
|
|
783
|
+
CREATE TABLE IF NOT EXISTS commands (
|
|
784
|
+
maid_id TEXT NOT NULL,
|
|
785
|
+
event_id TEXT NOT NULL,
|
|
786
|
+
turn_key TEXT,
|
|
787
|
+
kind TEXT NOT NULL,
|
|
788
|
+
status TEXT NOT NULL,
|
|
789
|
+
occurred_at INTEGER NOT NULL,
|
|
790
|
+
command_hash TEXT,
|
|
791
|
+
payload_json TEXT,
|
|
792
|
+
source_config_revision TEXT,
|
|
793
|
+
applied_revision INTEGER,
|
|
794
|
+
error_code TEXT,
|
|
795
|
+
PRIMARY KEY (maid_id, event_id)
|
|
796
|
+
);
|
|
797
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
798
|
+
maid_id TEXT PRIMARY KEY,
|
|
799
|
+
state_revision INTEGER NOT NULL,
|
|
800
|
+
config_revision TEXT NOT NULL,
|
|
801
|
+
server_time INTEGER NOT NULL,
|
|
802
|
+
snapshot_json TEXT NOT NULL
|
|
803
|
+
);
|
|
804
|
+
`;
|
|
805
|
+
function rowToCommand(row) {
|
|
806
|
+
if (row.payload_json === null) return void 0;
|
|
807
|
+
return JSON.parse(row.payload_json);
|
|
808
|
+
}
|
|
809
|
+
var ClientJournal = class {
|
|
810
|
+
#db;
|
|
811
|
+
#hostId;
|
|
812
|
+
#maxPending;
|
|
813
|
+
constructor(options) {
|
|
814
|
+
mkdirSync(dirname(options.file), { recursive: true });
|
|
815
|
+
this.#db = new DatabaseSync(options.file, { readOnly: false });
|
|
816
|
+
try {
|
|
817
|
+
chmodSync(options.file, 384);
|
|
818
|
+
} catch {}
|
|
819
|
+
this.#db.exec("PRAGMA journal_mode=WAL");
|
|
820
|
+
this.#db.exec(SCHEMA);
|
|
821
|
+
this.#hostId = options.hostId;
|
|
822
|
+
this.#maxPending = options.maxPendingEvents ?? DEFAULT_MAX_PENDING;
|
|
823
|
+
}
|
|
824
|
+
#rowOf(maidId, eventId) {
|
|
825
|
+
return this.#db.prepare("SELECT * FROM commands WHERE maid_id = ? AND event_id = ?").get(maidId, eventId);
|
|
826
|
+
}
|
|
827
|
+
#pendingCount(maidId) {
|
|
828
|
+
return this.#db.prepare("SELECT COUNT(*) AS n FROM commands WHERE maid_id = ? AND status IN ('reserved','awaiting_config','pending')").get(maidId).n;
|
|
829
|
+
}
|
|
830
|
+
/** turn 预留(判分前):turnKey 查重(hostId 用本 journal 构造身份,调用方无须知晓),新轮插入 reserved */
|
|
831
|
+
reserveTurn(input) {
|
|
832
|
+
const eventId = turnEventId({
|
|
833
|
+
hostId: this.#hostId,
|
|
834
|
+
sessionId: input.sessionId,
|
|
835
|
+
turn: input.turn
|
|
836
|
+
});
|
|
837
|
+
return this.#reserveInTx(eventId, input);
|
|
838
|
+
}
|
|
839
|
+
#reserveInTx(eventId, input) {
|
|
840
|
+
const existing = this.#rowOf(input.maidId, eventId);
|
|
841
|
+
if (existing !== void 0) {
|
|
842
|
+
if (existing.status === "reserved" || existing.status === "awaiting_config") return {
|
|
843
|
+
kind: "resume",
|
|
844
|
+
eventId,
|
|
845
|
+
draft: existing.payload_json === null ? input.draft : JSON.parse(existing.payload_json)
|
|
846
|
+
};
|
|
847
|
+
return {
|
|
848
|
+
kind: "duplicate",
|
|
849
|
+
eventId
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
if (this.#pendingCount(input.maidId) >= this.#maxPending) return {
|
|
853
|
+
kind: "full",
|
|
854
|
+
eventId: null
|
|
855
|
+
};
|
|
856
|
+
this.#db.prepare("INSERT INTO commands (maid_id, event_id, turn_key, kind, status, occurred_at, payload_json) VALUES (?, ?, ?, 'emotion.settle_turn', 'reserved', ?, ?)").run(input.maidId, eventId, `${this.#hostId}\n${input.sessionId}\n${input.turn}`, input.occurredAt, JSON.stringify(input.draft));
|
|
857
|
+
return {
|
|
858
|
+
kind: "new",
|
|
859
|
+
eventId,
|
|
860
|
+
draft: input.draft
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
/** 预留记录查询(reserved/awaiting/pending/acked/failed 任意状态;draft 仅预留态有语义) */
|
|
864
|
+
reservationOf(eventId) {
|
|
865
|
+
const row = this.#findByEventId(eventId);
|
|
866
|
+
return row === void 0 ? void 0 : {
|
|
867
|
+
maidId: row.maid_id,
|
|
868
|
+
status: row.status
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
/** reserved/awaiting_config → pending(定稿):draft 从预留行内部取(builder 组装完整 payload) */
|
|
872
|
+
finalizeTurn(input) {
|
|
873
|
+
const found = this.#findByEventId(input.eventId);
|
|
874
|
+
if (found === void 0 || found.status !== "reserved" && found.status !== "awaiting_config") return null;
|
|
875
|
+
const draft = found.payload_json === null ? {} : JSON.parse(found.payload_json);
|
|
876
|
+
const command = {
|
|
877
|
+
protocolVersion: 1,
|
|
878
|
+
eventId: found.event_id,
|
|
879
|
+
hostId: this.#hostId,
|
|
880
|
+
maidId: found.maid_id,
|
|
881
|
+
kind: "emotion.settle_turn",
|
|
882
|
+
occurredAt: found.occurred_at,
|
|
883
|
+
sourceConfigRevision: input.sourceConfigRevision,
|
|
884
|
+
payload: input.build(draft)
|
|
885
|
+
};
|
|
886
|
+
this.#db.prepare("UPDATE commands SET status = 'pending', payload_json = ?, command_hash = ?, source_config_revision = ? WHERE maid_id = ? AND event_id = ?").run(JSON.stringify(command), commandHash(command), input.sourceConfigRevision, found.maid_id, found.event_id);
|
|
887
|
+
return command;
|
|
888
|
+
}
|
|
889
|
+
#findByEventId(eventId) {
|
|
890
|
+
return this.#db.prepare("SELECT * FROM commands WHERE event_id = ?").get(eventId);
|
|
891
|
+
}
|
|
892
|
+
/** gesture/reunion/manual:一次性持久化完整命令(幂等:重复 eventId 直接接受) */
|
|
893
|
+
enqueueCommand(command) {
|
|
894
|
+
if (this.#rowOf(command.maidId, command.eventId) !== void 0) return true;
|
|
895
|
+
if (this.#pendingCount(command.maidId) >= this.#maxPending) return false;
|
|
896
|
+
this.#db.prepare("INSERT INTO commands (maid_id, event_id, turn_key, kind, status, occurred_at, command_hash, payload_json, source_config_revision) VALUES (?, ?, NULL, ?, 'pending', ?, ?, ?, ?)").run(command.maidId, command.eventId, command.kind, command.occurredAt, commandHash(command), JSON.stringify(command), command.sourceConfigRevision);
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
/** reserved → awaiting_config(无配置快照的本地保留);仅 reserved 可转 */
|
|
900
|
+
markAwaitingConfig(eventId) {
|
|
901
|
+
const row = this.#findByEventId(eventId);
|
|
902
|
+
if (row === void 0 || row.status !== "reserved") return false;
|
|
903
|
+
this.#db.prepare("UPDATE commands SET status = 'awaiting_config' WHERE maid_id = ? AND event_id = ?").run(row.maid_id, eventId);
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
listAwaitingConfig(maidId) {
|
|
907
|
+
return this.#db.prepare("SELECT * FROM commands WHERE maid_id = ? AND status = 'awaiting_config' ORDER BY rowid").all(maidId).map((r) => ({
|
|
908
|
+
eventId: r.event_id,
|
|
909
|
+
kind: r.kind,
|
|
910
|
+
occurredAt: r.occurred_at,
|
|
911
|
+
draft: r.payload_json === null ? void 0 : JSON.parse(r.payload_json)
|
|
912
|
+
}));
|
|
913
|
+
}
|
|
914
|
+
listPending(maidId) {
|
|
915
|
+
return this.#db.prepare("SELECT * FROM commands WHERE maid_id = ? AND status = 'pending' ORDER BY rowid").all(maidId).map(rowToCommand).filter((c) => c !== void 0);
|
|
916
|
+
}
|
|
917
|
+
/** 确认:pending → acked,清 payload(原文清理),可选保存权威快照(revision 守卫) */
|
|
918
|
+
markAcked(eventId, ack) {
|
|
919
|
+
const row = this.#findByEventId(eventId);
|
|
920
|
+
if (row === void 0) return;
|
|
921
|
+
this.#db.prepare("UPDATE commands SET status = 'acked', applied_revision = ?, payload_json = NULL WHERE maid_id = ? AND event_id = ?").run(ack.appliedRevision, row.maid_id, eventId);
|
|
922
|
+
if (ack.snapshot !== void 0) this.saveSnapshot(ack.snapshot);
|
|
923
|
+
}
|
|
924
|
+
/** 永久失败:pending → failed,payload 保留供显式导出(不自动删除) */
|
|
925
|
+
markFailed(eventId, errorCode) {
|
|
926
|
+
const row = this.#findByEventId(eventId);
|
|
927
|
+
if (row === void 0) return;
|
|
928
|
+
this.#db.prepare("UPDATE commands SET status = 'failed', error_code = ? WHERE maid_id = ? AND event_id = ?").run(errorCode, row.maid_id, eventId);
|
|
929
|
+
}
|
|
930
|
+
exportFailed() {
|
|
931
|
+
return this.#db.prepare("SELECT * FROM commands WHERE status = 'failed' ORDER BY rowid").all().map((r) => ({
|
|
932
|
+
maidId: r.maid_id,
|
|
933
|
+
eventId: r.event_id,
|
|
934
|
+
command: rowToCommand(r),
|
|
935
|
+
errorCode: r.error_code
|
|
936
|
+
}));
|
|
937
|
+
}
|
|
938
|
+
/** 快照缓存:旧 revision 不回退;同 revision 允许新 serverTime(不回退)/config 更新 */
|
|
939
|
+
saveSnapshot(snapshot) {
|
|
940
|
+
const existing = this.#db.prepare("SELECT * FROM snapshots WHERE maid_id = ?").get(snapshot.maidId);
|
|
941
|
+
if (existing !== void 0) {
|
|
942
|
+
if (snapshot.stateRevision < existing.state_revision) return;
|
|
943
|
+
if (snapshot.stateRevision === existing.state_revision && snapshot.serverTime <= existing.server_time) {
|
|
944
|
+
if (snapshot.configRevision === existing.config_revision) return;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
this.#db.prepare("INSERT OR REPLACE INTO snapshots (maid_id, state_revision, config_revision, server_time, snapshot_json) VALUES (?, ?, ?, ?, ?)").run(snapshot.maidId, snapshot.stateRevision, snapshot.configRevision, snapshot.serverTime, JSON.stringify(snapshot));
|
|
948
|
+
}
|
|
949
|
+
/** 已知 maid 全集(快照 ∪ 命令表;M3h 预备状态面用)。 */
|
|
950
|
+
listMaids() {
|
|
951
|
+
const rows = this.#db.prepare("SELECT maid_id AS maid FROM snapshots UNION SELECT maid_id AS maid FROM commands").all();
|
|
952
|
+
return [...new Set(rows.map((row) => row.maid))];
|
|
953
|
+
}
|
|
954
|
+
readSnapshot(maidId) {
|
|
955
|
+
const row = this.#db.prepare("SELECT snapshot_json FROM snapshots WHERE maid_id = ?").get(maidId);
|
|
956
|
+
return row === void 0 ? void 0 : JSON.parse(row.snapshot_json);
|
|
957
|
+
}
|
|
958
|
+
close() {
|
|
959
|
+
this.#db.close();
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
//#endregion
|
|
963
|
+
//#region src/remote-client.ts
|
|
964
|
+
const DEFAULT_RETRY_INITIAL_MS = 1e3;
|
|
965
|
+
const DEFAULT_RETRY_MAX_MS = 12e4;
|
|
966
|
+
var RemoteStateClient = class {
|
|
967
|
+
journal;
|
|
968
|
+
#options;
|
|
969
|
+
#transport;
|
|
970
|
+
#project;
|
|
971
|
+
#chains = /* @__PURE__ */ new Map();
|
|
972
|
+
#timers = /* @__PURE__ */ new Map();
|
|
973
|
+
#closed = false;
|
|
974
|
+
constructor(options) {
|
|
975
|
+
this.journal = options.journal;
|
|
976
|
+
this.#transport = options.transport;
|
|
977
|
+
this.#project = options.project;
|
|
978
|
+
this.#options = {
|
|
979
|
+
now: options.now ?? (() => Date.now()),
|
|
980
|
+
random: options.random ?? Math.random,
|
|
981
|
+
retryInitialMs: options.retryInitialMs ?? DEFAULT_RETRY_INITIAL_MS,
|
|
982
|
+
retryMaxMs: options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
/** 同步读:base 快照 + pending 重算;无快照 → undefined(不注情绪、不发网络) */
|
|
986
|
+
read(maidId) {
|
|
987
|
+
const base = this.journal.readSnapshot(maidId);
|
|
988
|
+
if (base === void 0) return void 0;
|
|
989
|
+
const pending = this.journal.listPending(maidId);
|
|
990
|
+
const now = this.#options.now();
|
|
991
|
+
return {
|
|
992
|
+
state: this.#project(base, pending, now),
|
|
993
|
+
config: base.config,
|
|
994
|
+
stateRevision: base.stateRevision,
|
|
995
|
+
configRevision: base.configRevision,
|
|
996
|
+
staleSince: Math.max(0, now - base.serverTime),
|
|
997
|
+
outboxDepth: pending.length
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
/** 权威快照刷新:成功后 read() 以新 base 重算;失败沿用缓存(向调用方上抛由其决定) */
|
|
1001
|
+
async refresh(maidId) {
|
|
1002
|
+
return await this.#enqueue(maidId, async () => {
|
|
1003
|
+
const snapshot = await this.#transport.getSnapshot(maidId);
|
|
1004
|
+
this.journal.saveSnapshot(snapshot);
|
|
1005
|
+
this.#lastOkAt = this.#options.now();
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
/** FIFO flush(显式调用):成功重置退避;网络/临时错误中断并上抛(已 ack 的不回退) */
|
|
1009
|
+
async flush(maidId) {
|
|
1010
|
+
return await this.#enqueue(maidId, async () => this.#runFlush(maidId, true));
|
|
1011
|
+
}
|
|
1012
|
+
async #runFlush(maidId, manual) {
|
|
1013
|
+
this.#timers.delete(maidId);
|
|
1014
|
+
if (manual) this.#retryAttempts.set(maidId, 0);
|
|
1015
|
+
for (;;) {
|
|
1016
|
+
const [command] = this.journal.listPending(maidId);
|
|
1017
|
+
if (command === void 0) return;
|
|
1018
|
+
try {
|
|
1019
|
+
const receipt = await this.#transport.submit(command);
|
|
1020
|
+
if (receipt.status === "applied" || receipt.status === "duplicate") {
|
|
1021
|
+
this.journal.markAcked(command.eventId, {
|
|
1022
|
+
appliedRevision: receipt.appliedRevision,
|
|
1023
|
+
...receipt.snapshot !== null ? { snapshot: receipt.snapshot } : {}
|
|
1024
|
+
});
|
|
1025
|
+
this.#lastOkAt = this.#options.now();
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
this.journal.markFailed(command.eventId, receipt.errorCode);
|
|
1029
|
+
if (receipt.snapshot !== null) this.journal.saveSnapshot(receipt.snapshot);
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
if (error instanceof StateCommandError) throw error;
|
|
1032
|
+
this.#scheduleRetry(maidId);
|
|
1033
|
+
throw error;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
close() {
|
|
1038
|
+
this.#closed = true;
|
|
1039
|
+
for (const timer of this.#timers.values()) clearTimeout(timer);
|
|
1040
|
+
this.#timers.clear();
|
|
1041
|
+
}
|
|
1042
|
+
/** 指数退避 + 抖动:min(max, initial×2^n);timer unref */
|
|
1043
|
+
#scheduleRetry(maidId) {
|
|
1044
|
+
if (this.#closed) return;
|
|
1045
|
+
if (this.#timers.get(maidId) !== void 0) return;
|
|
1046
|
+
const attempt = this.#retryAttempts.get(maidId) ?? 0;
|
|
1047
|
+
this.#retryAttempts.set(maidId, attempt + 1);
|
|
1048
|
+
const delay = Math.min(this.#options.retryMaxMs, this.#options.retryInitialMs * 2 ** attempt);
|
|
1049
|
+
const jitter = Math.floor(delay * .2 * this.#options.random());
|
|
1050
|
+
const timer = setTimeout(() => {
|
|
1051
|
+
this.#timers.delete(maidId);
|
|
1052
|
+
this.#enqueue(maidId, async () => this.#runFlush(maidId, false)).catch(() => {});
|
|
1053
|
+
}, delay + jitter);
|
|
1054
|
+
timer.unref();
|
|
1055
|
+
this.#timers.set(maidId, timer);
|
|
1056
|
+
}
|
|
1057
|
+
#retryAttempts = /* @__PURE__ */ new Map();
|
|
1058
|
+
#lastOkAt = null;
|
|
1059
|
+
/** 可观测快照(M3h 预备):lastOkAt + 逐 maid 缓存陈旧度/outbox 深度;同步读不发网络。 */
|
|
1060
|
+
status() {
|
|
1061
|
+
return {
|
|
1062
|
+
lastOkAt: this.#lastOkAt,
|
|
1063
|
+
closed: this.#closed,
|
|
1064
|
+
maids: this.journal.listMaids().map((maidId) => {
|
|
1065
|
+
const base = this.journal.readSnapshot(maidId);
|
|
1066
|
+
const pending = this.journal.listPending(maidId).length;
|
|
1067
|
+
if (base === void 0) return {
|
|
1068
|
+
maidId,
|
|
1069
|
+
hasSnapshot: false,
|
|
1070
|
+
stateRevision: 0,
|
|
1071
|
+
staleSince: null,
|
|
1072
|
+
outboxDepth: pending
|
|
1073
|
+
};
|
|
1074
|
+
const now = this.#options.now();
|
|
1075
|
+
return {
|
|
1076
|
+
maidId,
|
|
1077
|
+
hasSnapshot: true,
|
|
1078
|
+
stateRevision: base.stateRevision,
|
|
1079
|
+
staleSince: Math.max(0, now - base.serverTime),
|
|
1080
|
+
outboxDepth: pending
|
|
1081
|
+
};
|
|
1082
|
+
})
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
/** per-maid 串行链:前序失败不阻断本命令;closed 后拒绝 */
|
|
1086
|
+
async #enqueue(maidId, run) {
|
|
1087
|
+
if (this.#closed) throw new Error("maid-state remote client closed");
|
|
1088
|
+
const next = (this.#chains.get(maidId) ?? Promise.resolve()).then(run, run);
|
|
1089
|
+
this.#chains.set(maidId, next.catch(() => {}));
|
|
1090
|
+
return await next;
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
//#endregion
|
|
1094
|
+
//#region src/embedded-transport.ts
|
|
1095
|
+
/**
|
|
1096
|
+
* M3j 内嵌传输(规格 §9.2):直接调用同进程 StateAuthority——无网络监听、无 TLS、
|
|
1097
|
+
* 无序列化边界。embedded 后端与 HTTP client 共用同一权威/命令/回执/revision 语义,
|
|
1098
|
+
* 由结构保证「同一输入同一结果」;唯一权威仍是 authority.db,本 transport 不持状态。
|
|
1099
|
+
*/
|
|
1100
|
+
var EmbeddedStateTransport = class {
|
|
1101
|
+
authority;
|
|
1102
|
+
constructor(authority) {
|
|
1103
|
+
this.authority = authority;
|
|
1104
|
+
}
|
|
1105
|
+
async getSnapshot(maidId) {
|
|
1106
|
+
return this.authority.snapshot(maidId);
|
|
1107
|
+
}
|
|
1108
|
+
async submit(command) {
|
|
1109
|
+
return await this.authority.submit(command);
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
//#endregion
|
|
1113
|
+
export { ClientJournal, EmbeddedStateTransport, PROTOCOL_VERSION, RemoteStateClient, StateAuthority, StateCommandError, StateHttpClient, acquireDataDirLock, assertStateCommand, canonicalJson, commandHash, identityOfPeer, loadKeyCertCa, maidAllowed, newEventId, normalizeFingerprint, openAuthorityStore, revisionOf, startStateHttpServer, turnEventId };
|