opencode-oncall 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +151 -0
- package/README.md +50 -0
- package/dist/common-settings-actions.d.ts +15 -0
- package/dist/common-settings-actions.js +48 -0
- package/dist/common-settings-store.d.ts +1 -0
- package/dist/common-settings-store.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plugin-hooks.d.ts +51 -0
- package/dist/plugin-hooks.js +288 -0
- package/dist/plugin.d.ts +10 -0
- package/dist/plugin.js +115 -0
- package/dist/settings-store.d.ts +50 -0
- package/dist/settings-store.js +214 -0
- package/dist/store-paths.d.ts +16 -0
- package/dist/store-paths.js +61 -0
- package/dist/ui/wechat-menu.d.ts +26 -0
- package/dist/ui/wechat-menu.js +90 -0
- package/dist/wechat/bind-flow.d.ts +29 -0
- package/dist/wechat/bind-flow.js +207 -0
- package/dist/wechat/bridge.d.ts +136 -0
- package/dist/wechat/bridge.js +1059 -0
- package/dist/wechat/broker-client.d.ts +23 -0
- package/dist/wechat/broker-client.js +274 -0
- package/dist/wechat/broker-endpoint.d.ts +21 -0
- package/dist/wechat/broker-endpoint.js +78 -0
- package/dist/wechat/broker-entry.d.ts +123 -0
- package/dist/wechat/broker-entry.js +1321 -0
- package/dist/wechat/broker-launcher.d.ts +37 -0
- package/dist/wechat/broker-launcher.js +418 -0
- package/dist/wechat/broker-mutation-queue.d.ts +93 -0
- package/dist/wechat/broker-mutation-queue.js +126 -0
- package/dist/wechat/broker-server.d.ts +86 -0
- package/dist/wechat/broker-server.js +1340 -0
- package/dist/wechat/broker-state-store.d.ts +335 -0
- package/dist/wechat/broker-state-store.js +1964 -0
- package/dist/wechat/command-parser.d.ts +18 -0
- package/dist/wechat/command-parser.js +58 -0
- package/dist/wechat/compat/jiti-loader.d.ts +27 -0
- package/dist/wechat/compat/jiti-loader.js +118 -0
- package/dist/wechat/compat/openclaw-account-helpers.d.ts +29 -0
- package/dist/wechat/compat/openclaw-account-helpers.js +60 -0
- package/dist/wechat/compat/openclaw-bind-helpers.d.ts +29 -0
- package/dist/wechat/compat/openclaw-bind-helpers.js +169 -0
- package/dist/wechat/compat/openclaw-guided-smoke.d.ts +180 -0
- package/dist/wechat/compat/openclaw-guided-smoke.js +1134 -0
- package/dist/wechat/compat/openclaw-public-entry.d.ts +33 -0
- package/dist/wechat/compat/openclaw-public-entry.js +62 -0
- package/dist/wechat/compat/openclaw-public-helpers.d.ts +70 -0
- package/dist/wechat/compat/openclaw-public-helpers.js +68 -0
- package/dist/wechat/compat/openclaw-qr-gateway.d.ts +15 -0
- package/dist/wechat/compat/openclaw-qr-gateway.js +39 -0
- package/dist/wechat/compat/openclaw-smoke.d.ts +48 -0
- package/dist/wechat/compat/openclaw-smoke.js +100 -0
- package/dist/wechat/compat/openclaw-sync-buf.d.ts +24 -0
- package/dist/wechat/compat/openclaw-sync-buf.js +80 -0
- package/dist/wechat/compat/openclaw-updates-send.d.ts +47 -0
- package/dist/wechat/compat/openclaw-updates-send.js +38 -0
- package/dist/wechat/compat/qrcode-terminal-loader.d.ts +12 -0
- package/dist/wechat/compat/qrcode-terminal-loader.js +16 -0
- package/dist/wechat/compat/slash-guard.d.ts +11 -0
- package/dist/wechat/compat/slash-guard.js +24 -0
- package/dist/wechat/dead-letter-store.d.ts +48 -0
- package/dist/wechat/dead-letter-store.js +224 -0
- package/dist/wechat/debug-bundle-collector.d.ts +49 -0
- package/dist/wechat/debug-bundle-collector.js +580 -0
- package/dist/wechat/debug-bundle-flow.d.ts +37 -0
- package/dist/wechat/debug-bundle-flow.js +180 -0
- package/dist/wechat/debug-bundle-redaction.d.ts +14 -0
- package/dist/wechat/debug-bundle-redaction.js +339 -0
- package/dist/wechat/handle.d.ts +10 -0
- package/dist/wechat/handle.js +57 -0
- package/dist/wechat/ipc-auth.d.ts +6 -0
- package/dist/wechat/ipc-auth.js +39 -0
- package/dist/wechat/latest-account-state-store.d.ts +8 -0
- package/dist/wechat/latest-account-state-store.js +38 -0
- package/dist/wechat/notification-dispatcher.d.ts +34 -0
- package/dist/wechat/notification-dispatcher.js +266 -0
- package/dist/wechat/notification-format.d.ts +15 -0
- package/dist/wechat/notification-format.js +196 -0
- package/dist/wechat/notification-store.d.ts +72 -0
- package/dist/wechat/notification-store.js +807 -0
- package/dist/wechat/notification-types.d.ts +37 -0
- package/dist/wechat/notification-types.js +1 -0
- package/dist/wechat/openclaw-account-adapter.d.ts +30 -0
- package/dist/wechat/openclaw-account-adapter.js +60 -0
- package/dist/wechat/operator-store.d.ts +9 -0
- package/dist/wechat/operator-store.js +69 -0
- package/dist/wechat/protocol.d.ts +150 -0
- package/dist/wechat/protocol.js +197 -0
- package/dist/wechat/question-interaction.d.ts +24 -0
- package/dist/wechat/question-interaction.js +180 -0
- package/dist/wechat/request-store.d.ts +108 -0
- package/dist/wechat/request-store.js +669 -0
- package/dist/wechat/session-digest.d.ts +50 -0
- package/dist/wechat/session-digest.js +167 -0
- package/dist/wechat/state-paths.d.ts +26 -0
- package/dist/wechat/state-paths.js +92 -0
- package/dist/wechat/status-format.d.ts +26 -0
- package/dist/wechat/status-format.js +616 -0
- package/dist/wechat/token-store.d.ts +20 -0
- package/dist/wechat/token-store.js +193 -0
- package/dist/wechat/wechat-status-runtime.d.ts +89 -0
- package/dist/wechat/wechat-status-runtime.js +518 -0
- package/package.json +74 -0
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
4
|
+
import { connect } from "./broker-client.js";
|
|
5
|
+
import { connectOrSpawnBroker } from "./broker-launcher.js";
|
|
6
|
+
import { WECHAT_FILE_MODE, wechatBridgeDiagnosticsPath } from "./state-paths.js";
|
|
7
|
+
import { buildSessionDigest, groupPermissionsBySession, groupQuestionsBySession, pickRecentSessions, } from "./session-digest.js";
|
|
8
|
+
import { readOperatorBinding } from "./operator-store.js";
|
|
9
|
+
import { createHandle, createRouteKey, createSessionReplyHandle } from "./handle.js";
|
|
10
|
+
import { extractPermissionPromptSummary, extractQuestionPromptSummary } from "./question-interaction.js";
|
|
11
|
+
function indexRequestCandidates(candidates, kind) {
|
|
12
|
+
const indexed = new Map();
|
|
13
|
+
for (const candidate of candidates) {
|
|
14
|
+
if (candidate.kind !== kind) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
indexed.set(candidate.routeKey, candidate);
|
|
18
|
+
}
|
|
19
|
+
return indexed;
|
|
20
|
+
}
|
|
21
|
+
function indexNaturalStopCandidates(candidates) {
|
|
22
|
+
const indexed = new Map();
|
|
23
|
+
for (const candidate of candidates) {
|
|
24
|
+
if (candidate.kind !== "naturalStop") {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
indexed.set(candidate.handle, candidate);
|
|
28
|
+
}
|
|
29
|
+
return indexed;
|
|
30
|
+
}
|
|
31
|
+
const DEFAULT_LIVE_READ_TIMEOUT_MS = 2_000;
|
|
32
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
33
|
+
const DEFAULT_BRIDGE_PROTOCOL_VERSION = 2;
|
|
34
|
+
const DEFAULT_BRIDGE_STATE_GENERATION = "wechat-ws-v1";
|
|
35
|
+
const PROCESS_INSTANCE_ID = toSafeInstanceID(`wechat-${process.pid}-${randomUUID().slice(0, 8)}`);
|
|
36
|
+
function toSafeInstanceID(input) {
|
|
37
|
+
const normalized = input.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
38
|
+
if (normalized.length === 0) {
|
|
39
|
+
return `wechat-${process.pid}`;
|
|
40
|
+
}
|
|
41
|
+
return normalized.slice(0, 64);
|
|
42
|
+
}
|
|
43
|
+
function isNonEmptyString(value) {
|
|
44
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
45
|
+
}
|
|
46
|
+
function toProjectName(project) {
|
|
47
|
+
if (typeof project?.name === "string" && project.name.trim().length > 0) {
|
|
48
|
+
return project.name.trim();
|
|
49
|
+
}
|
|
50
|
+
if (typeof project?.id === "string" && project.id.trim().length > 0) {
|
|
51
|
+
return project.id.trim();
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
function toDirectory(inputDirectory) {
|
|
56
|
+
if (typeof inputDirectory === "string" && inputDirectory.trim().length > 0) {
|
|
57
|
+
return inputDirectory;
|
|
58
|
+
}
|
|
59
|
+
return process.cwd();
|
|
60
|
+
}
|
|
61
|
+
function toInstanceName(projectName, directory) {
|
|
62
|
+
if (projectName) {
|
|
63
|
+
return projectName;
|
|
64
|
+
}
|
|
65
|
+
const parts = directory.split(/[\\/]+/).filter((part) => part.length > 0);
|
|
66
|
+
return parts.at(-1) ?? `wechat-${process.pid}`;
|
|
67
|
+
}
|
|
68
|
+
function toInstanceID(projectName, directory) {
|
|
69
|
+
const seed = projectName ?? directory;
|
|
70
|
+
return toSafeInstanceID(seed);
|
|
71
|
+
}
|
|
72
|
+
function withTimeout(task, timeoutMs, name) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
let settled = false;
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
if (settled) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
settled = true;
|
|
80
|
+
reject(new Error(`${name} timed out in ${timeoutMs}ms`));
|
|
81
|
+
}, timeoutMs);
|
|
82
|
+
void Promise.resolve()
|
|
83
|
+
.then(task)
|
|
84
|
+
.then((value) => {
|
|
85
|
+
if (settled) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
settled = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
resolve(value);
|
|
91
|
+
})
|
|
92
|
+
.catch((error) => {
|
|
93
|
+
if (settled) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
settled = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
reject(error);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function isErrorWithMessage(value) {
|
|
103
|
+
return typeof value === "object" && value !== null && "message" in value && typeof value.message === "string";
|
|
104
|
+
}
|
|
105
|
+
function createWechatBridgeDiagnosticsWriter(filePath = wechatBridgeDiagnosticsPath()) {
|
|
106
|
+
let warned = false;
|
|
107
|
+
return async (event) => {
|
|
108
|
+
try {
|
|
109
|
+
await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
110
|
+
const line = `${JSON.stringify({ timestamp: Date.now(), ...event })}\n`;
|
|
111
|
+
await appendFile(filePath, line, { encoding: "utf8", mode: WECHAT_FILE_MODE });
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (!warned) {
|
|
115
|
+
warned = true;
|
|
116
|
+
console.warn("[wechat-bridge] failed to write diagnostics", error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function toDiagnosticErrorMessage(error) {
|
|
122
|
+
if (isErrorWithMessage(error)) {
|
|
123
|
+
return error.message;
|
|
124
|
+
}
|
|
125
|
+
return String(error);
|
|
126
|
+
}
|
|
127
|
+
function wrapDiagnosticStage(input, task) {
|
|
128
|
+
void input.instanceID;
|
|
129
|
+
void input.stage;
|
|
130
|
+
void input.onDiagnosticEvent;
|
|
131
|
+
return Promise.resolve().then(task);
|
|
132
|
+
}
|
|
133
|
+
function isSdkFieldsResult(value) {
|
|
134
|
+
return typeof value === "object"
|
|
135
|
+
&& value !== null
|
|
136
|
+
&& ("data" in value || "error" in value);
|
|
137
|
+
}
|
|
138
|
+
function unwrapSdkReadResult(value, name) {
|
|
139
|
+
if (!isSdkFieldsResult(value)) {
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
if (value.error != null) {
|
|
143
|
+
throw value.error instanceof Error ? value.error : new Error(`${name} failed`);
|
|
144
|
+
}
|
|
145
|
+
if (value.data === undefined) {
|
|
146
|
+
throw new Error(`${name} returned no data`);
|
|
147
|
+
}
|
|
148
|
+
return value.data;
|
|
149
|
+
}
|
|
150
|
+
function normalizeReplyMutationResult(mutationId, value) {
|
|
151
|
+
if (isSdkFieldsResult(value) && value.error != null) {
|
|
152
|
+
return {
|
|
153
|
+
mutationId,
|
|
154
|
+
ok: false,
|
|
155
|
+
errorMessage: toDiagnosticErrorMessage(value.error),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
mutationId,
|
|
160
|
+
ok: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function asStatusRecord(status) {
|
|
164
|
+
if (typeof status !== "object" || status === null) {
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
167
|
+
return status;
|
|
168
|
+
}
|
|
169
|
+
function readStatusText(status, key) {
|
|
170
|
+
const value = asStatusRecord(status)[key];
|
|
171
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
172
|
+
}
|
|
173
|
+
function readStatusNumber(status, key) {
|
|
174
|
+
const value = asStatusRecord(status)[key];
|
|
175
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
176
|
+
}
|
|
177
|
+
function isSensitiveSummary(summary) {
|
|
178
|
+
return /\n|\r|stack|trace|token|secret|password|authorization|cookie|set-cookie|bearer/i.test(summary);
|
|
179
|
+
}
|
|
180
|
+
function deriveStatusSummary(status) {
|
|
181
|
+
const explicit = readStatusText(status, "redactedSummary");
|
|
182
|
+
if (explicit) {
|
|
183
|
+
return explicit;
|
|
184
|
+
}
|
|
185
|
+
const message = readStatusText(status, "message");
|
|
186
|
+
if (!message || isSensitiveSummary(message)) {
|
|
187
|
+
return "原因摘要不可安全展示";
|
|
188
|
+
}
|
|
189
|
+
return message;
|
|
190
|
+
}
|
|
191
|
+
function deriveRetryAction(status) {
|
|
192
|
+
const explicit = readStatusText(status, "action") ?? readStatusText(status, "stage");
|
|
193
|
+
if (explicit) {
|
|
194
|
+
return explicit;
|
|
195
|
+
}
|
|
196
|
+
const attempt = readStatusNumber(status, "attempt");
|
|
197
|
+
if (typeof attempt === "number") {
|
|
198
|
+
return `自动重试第 ${attempt} 次`;
|
|
199
|
+
}
|
|
200
|
+
return "重试处理中";
|
|
201
|
+
}
|
|
202
|
+
function deriveRetrySeverity(status) {
|
|
203
|
+
const explicit = readStatusText(status, "severityAdvice");
|
|
204
|
+
if (explicit === "可等待自动重试" || explicit === "建议尽快人工查看") {
|
|
205
|
+
return explicit;
|
|
206
|
+
}
|
|
207
|
+
const attempt = readStatusNumber(status, "attempt");
|
|
208
|
+
if (typeof attempt === "number" && attempt > 1) {
|
|
209
|
+
return "建议尽快人工查看";
|
|
210
|
+
}
|
|
211
|
+
return "可等待自动重试";
|
|
212
|
+
}
|
|
213
|
+
function deriveNaturalStopSeverity(status) {
|
|
214
|
+
void status;
|
|
215
|
+
return "已停止并等待你的回复";
|
|
216
|
+
}
|
|
217
|
+
function stableBridgeSignature(value) {
|
|
218
|
+
if (value === null || typeof value !== "object") {
|
|
219
|
+
return JSON.stringify(value);
|
|
220
|
+
}
|
|
221
|
+
if (Array.isArray(value)) {
|
|
222
|
+
return `[${value.map((item) => stableBridgeSignature(item)).join(",")}]`;
|
|
223
|
+
}
|
|
224
|
+
const record = value;
|
|
225
|
+
const keys = Object.keys(record).sort();
|
|
226
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableBridgeSignature(record[key])}`).join(",")}}`;
|
|
227
|
+
}
|
|
228
|
+
export function createWechatBridge(input) {
|
|
229
|
+
const retryEventSequenceBySessionID = new Map();
|
|
230
|
+
const retrySignatureBySessionID = new Map();
|
|
231
|
+
const isRetryBySessionID = new Map();
|
|
232
|
+
const naturalStopEventSequenceBySessionID = new Map();
|
|
233
|
+
const naturalStopSignatureBySessionID = new Map();
|
|
234
|
+
const isNaturalStopBySessionID = new Map();
|
|
235
|
+
function toIdempotencyPart(value) {
|
|
236
|
+
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
237
|
+
return normalized.length > 0 ? normalized : "na";
|
|
238
|
+
}
|
|
239
|
+
function stableStringify(value) {
|
|
240
|
+
if (value === null || typeof value !== "object") {
|
|
241
|
+
return JSON.stringify(value);
|
|
242
|
+
}
|
|
243
|
+
if (Array.isArray(value)) {
|
|
244
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
245
|
+
}
|
|
246
|
+
const record = value;
|
|
247
|
+
const keys = Object.keys(record).sort();
|
|
248
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
249
|
+
}
|
|
250
|
+
const collectLiveRead = async () => {
|
|
251
|
+
const liveReadTimeoutMs = typeof input.liveReadTimeoutMs === "number" && Number.isFinite(input.liveReadTimeoutMs)
|
|
252
|
+
? Math.max(1, Math.floor(input.liveReadTimeoutMs))
|
|
253
|
+
: DEFAULT_LIVE_READ_TIMEOUT_MS;
|
|
254
|
+
const onDiagnosticEvent = input.onDiagnosticEvent;
|
|
255
|
+
const [sessionListResult, statusResult, questionResult, permissionResult] = await Promise.allSettled([
|
|
256
|
+
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "session.list", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.session.list(), "session.list"), liveReadTimeoutMs, "session.list")),
|
|
257
|
+
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "session.status", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.session.status(), "session.status"), liveReadTimeoutMs, "session.status")),
|
|
258
|
+
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "question.list", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.question.list(), "question.list"), liveReadTimeoutMs, "question.list")),
|
|
259
|
+
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "permission.list", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.permission.list(), "permission.list"), liveReadTimeoutMs, "permission.list")),
|
|
260
|
+
]);
|
|
261
|
+
return {
|
|
262
|
+
liveReadTimeoutMs,
|
|
263
|
+
sessionListResult,
|
|
264
|
+
statusResult,
|
|
265
|
+
questionResult,
|
|
266
|
+
permissionResult,
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
const collectStatusSnapshot = async () => {
|
|
270
|
+
const startedAt = Date.now();
|
|
271
|
+
const unavailable = new Set();
|
|
272
|
+
const onDiagnosticEvent = input.onDiagnosticEvent;
|
|
273
|
+
const activeSessionID = input.getActiveSessionID?.();
|
|
274
|
+
if (input.getActiveSessionID && !isNonEmptyString(activeSessionID)) {
|
|
275
|
+
const snapshot = {
|
|
276
|
+
instanceID: input.instanceID,
|
|
277
|
+
instanceName: input.instanceName,
|
|
278
|
+
pid: input.pid,
|
|
279
|
+
projectName: input.projectName,
|
|
280
|
+
directory: input.directory,
|
|
281
|
+
collectedAt: Date.now(),
|
|
282
|
+
sessions: [],
|
|
283
|
+
unavailable: undefined,
|
|
284
|
+
};
|
|
285
|
+
void Promise.resolve(onDiagnosticEvent?.({
|
|
286
|
+
type: "collectStatusCompleted",
|
|
287
|
+
instanceID: input.instanceID,
|
|
288
|
+
durationMs: Date.now() - startedAt,
|
|
289
|
+
sessionCount: 0,
|
|
290
|
+
unavailable: snapshot.unavailable,
|
|
291
|
+
})).catch(() => { });
|
|
292
|
+
return snapshot;
|
|
293
|
+
}
|
|
294
|
+
const { liveReadTimeoutMs, sessionListResult, statusResult, questionResult, permissionResult, } = await collectLiveRead();
|
|
295
|
+
const sessions = sessionListResult.status === "fulfilled" ? sessionListResult.value : [];
|
|
296
|
+
const recentSessions = isNonEmptyString(activeSessionID)
|
|
297
|
+
? sessions.filter((session) => session.id === activeSessionID).slice(0, 1)
|
|
298
|
+
: pickRecentSessions(sessions, 3);
|
|
299
|
+
if (sessionListResult.status === "rejected") {
|
|
300
|
+
unavailable.add("sessionStatus");
|
|
301
|
+
}
|
|
302
|
+
const statusBySession = statusResult.status === "fulfilled"
|
|
303
|
+
? statusResult.value
|
|
304
|
+
: (unavailable.add("sessionStatus"), {});
|
|
305
|
+
const questionsBySession = questionResult.status === "fulfilled"
|
|
306
|
+
? groupQuestionsBySession(questionResult.value)
|
|
307
|
+
: (unavailable.add("questionList"), groupQuestionsBySession([]));
|
|
308
|
+
const permissionsBySession = permissionResult.status === "fulfilled"
|
|
309
|
+
? groupPermissionsBySession(permissionResult.value)
|
|
310
|
+
: (unavailable.add("permissionList"), groupPermissionsBySession([]));
|
|
311
|
+
const sessionDigests = await Promise.all(recentSessions.map(async (session) => {
|
|
312
|
+
const [todoResult, messagesResult] = await Promise.allSettled([
|
|
313
|
+
wrapDiagnosticStage({ instanceID: input.instanceID, stage: `session.todo:${session.id}`, onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.session.todo({ sessionID: session.id }), `session.todo:${session.id}`), liveReadTimeoutMs, `session.todo:${session.id}`)),
|
|
314
|
+
wrapDiagnosticStage({ instanceID: input.instanceID, stage: `session.messages:${session.id}`, onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.session.messages({ sessionID: session.id, limit: 1 }), `session.messages:${session.id}`), liveReadTimeoutMs, `session.messages:${session.id}`)),
|
|
315
|
+
]);
|
|
316
|
+
const sessionUnavailable = [];
|
|
317
|
+
const todos = todoResult.status === "fulfilled" ? todoResult.value : (sessionUnavailable.push("todo"), []);
|
|
318
|
+
const messages = messagesResult.status === "fulfilled"
|
|
319
|
+
? messagesResult.value
|
|
320
|
+
: (sessionUnavailable.push("messages"), []);
|
|
321
|
+
return buildSessionDigest({
|
|
322
|
+
session,
|
|
323
|
+
statusBySession,
|
|
324
|
+
questionsBySession,
|
|
325
|
+
permissionsBySession,
|
|
326
|
+
todos,
|
|
327
|
+
messages,
|
|
328
|
+
unavailable: sessionUnavailable,
|
|
329
|
+
});
|
|
330
|
+
}));
|
|
331
|
+
const snapshot = {
|
|
332
|
+
instanceID: input.instanceID,
|
|
333
|
+
instanceName: input.instanceName,
|
|
334
|
+
pid: input.pid,
|
|
335
|
+
projectName: input.projectName,
|
|
336
|
+
directory: input.directory,
|
|
337
|
+
collectedAt: Date.now(),
|
|
338
|
+
sessions: sessionDigests,
|
|
339
|
+
unavailable: unavailable.size > 0 ? [...unavailable] : undefined,
|
|
340
|
+
};
|
|
341
|
+
void Promise.resolve(onDiagnosticEvent?.({
|
|
342
|
+
type: "collectStatusCompleted",
|
|
343
|
+
instanceID: input.instanceID,
|
|
344
|
+
durationMs: Date.now() - startedAt,
|
|
345
|
+
sessionCount: snapshot.sessions.length,
|
|
346
|
+
unavailable: snapshot.unavailable,
|
|
347
|
+
})).catch(() => { });
|
|
348
|
+
return snapshot;
|
|
349
|
+
};
|
|
350
|
+
const collectNotificationCandidateSnapshot = async () => {
|
|
351
|
+
const binding = await readOperatorBinding().catch(() => undefined);
|
|
352
|
+
if (!binding) {
|
|
353
|
+
return {
|
|
354
|
+
candidates: [],
|
|
355
|
+
authoritative: false,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const { questionResult, permissionResult, statusResult } = await collectLiveRead();
|
|
359
|
+
const candidates = [];
|
|
360
|
+
const existingHandles = new Set();
|
|
361
|
+
const authoritative = questionResult.status === "fulfilled"
|
|
362
|
+
&& permissionResult.status === "fulfilled"
|
|
363
|
+
&& statusResult.status === "fulfilled";
|
|
364
|
+
if (questionResult.status === "fulfilled") {
|
|
365
|
+
for (const question of questionResult.value) {
|
|
366
|
+
const routeKey = createRouteKey({
|
|
367
|
+
kind: "question",
|
|
368
|
+
requestID: question.id,
|
|
369
|
+
scopeKey: input.instanceID,
|
|
370
|
+
});
|
|
371
|
+
const handle = createHandle("question", existingHandles);
|
|
372
|
+
existingHandles.add(handle);
|
|
373
|
+
candidates.push({
|
|
374
|
+
idempotencyKey: `question-${toIdempotencyPart(input.instanceID)}-${toIdempotencyPart(question.id)}`,
|
|
375
|
+
kind: "question",
|
|
376
|
+
requestID: question.id,
|
|
377
|
+
createdAt: Date.now(),
|
|
378
|
+
routeKey,
|
|
379
|
+
handle,
|
|
380
|
+
scopeKey: input.instanceID,
|
|
381
|
+
wechatAccountId: binding.wechatAccountId,
|
|
382
|
+
userId: binding.userId,
|
|
383
|
+
prompt: extractQuestionPromptSummary(question),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (permissionResult.status === "fulfilled") {
|
|
388
|
+
for (const permission of permissionResult.value) {
|
|
389
|
+
const routeKey = createRouteKey({
|
|
390
|
+
kind: "permission",
|
|
391
|
+
requestID: permission.id,
|
|
392
|
+
scopeKey: input.instanceID,
|
|
393
|
+
});
|
|
394
|
+
const handle = createHandle("permission", existingHandles);
|
|
395
|
+
existingHandles.add(handle);
|
|
396
|
+
candidates.push({
|
|
397
|
+
idempotencyKey: `permission-${toIdempotencyPart(input.instanceID)}-${toIdempotencyPart(permission.id)}`,
|
|
398
|
+
kind: "permission",
|
|
399
|
+
requestID: permission.id,
|
|
400
|
+
createdAt: Date.now(),
|
|
401
|
+
routeKey,
|
|
402
|
+
handle,
|
|
403
|
+
scopeKey: input.instanceID,
|
|
404
|
+
wechatAccountId: binding.wechatAccountId,
|
|
405
|
+
userId: binding.userId,
|
|
406
|
+
prompt: extractPermissionPromptSummary(permission),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (statusResult.status === "fulfilled") {
|
|
411
|
+
const seenSessionIDs = new Set();
|
|
412
|
+
for (const [sessionID, status] of Object.entries(statusResult.value)) {
|
|
413
|
+
seenSessionIDs.add(sessionID);
|
|
414
|
+
const statusType = readStatusText(status, "type");
|
|
415
|
+
if (statusType === "retry") {
|
|
416
|
+
const signature = stableStringify(status);
|
|
417
|
+
const previousWasRetry = isRetryBySessionID.get(sessionID) === true;
|
|
418
|
+
const previousSignature = retrySignatureBySessionID.get(sessionID);
|
|
419
|
+
if (!previousWasRetry || previousSignature !== signature) {
|
|
420
|
+
const nextSequence = (retryEventSequenceBySessionID.get(sessionID) ?? 0) + 1;
|
|
421
|
+
retryEventSequenceBySessionID.set(sessionID, nextSequence);
|
|
422
|
+
}
|
|
423
|
+
isRetryBySessionID.set(sessionID, true);
|
|
424
|
+
retrySignatureBySessionID.set(sessionID, signature);
|
|
425
|
+
const eventSequence = retryEventSequenceBySessionID.get(sessionID) ?? 1;
|
|
426
|
+
candidates.push({
|
|
427
|
+
idempotencyKey: `session-error-${toIdempotencyPart(input.instanceID)}-${toIdempotencyPart(sessionID)}-${eventSequence}`,
|
|
428
|
+
kind: "sessionError",
|
|
429
|
+
createdAt: Date.now(),
|
|
430
|
+
sessionID,
|
|
431
|
+
action: deriveRetryAction(status),
|
|
432
|
+
redactedSummary: deriveStatusSummary(status),
|
|
433
|
+
severityAdvice: deriveRetrySeverity(status),
|
|
434
|
+
});
|
|
435
|
+
isNaturalStopBySessionID.set(sessionID, false);
|
|
436
|
+
naturalStopSignatureBySessionID.delete(sessionID);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (statusType === "natural-stop") {
|
|
440
|
+
const signature = stableStringify(status);
|
|
441
|
+
const previousWasNaturalStop = isNaturalStopBySessionID.get(sessionID) === true;
|
|
442
|
+
const previousSignature = naturalStopSignatureBySessionID.get(sessionID);
|
|
443
|
+
if (!previousWasNaturalStop || previousSignature !== signature) {
|
|
444
|
+
const nextSequence = (naturalStopEventSequenceBySessionID.get(sessionID) ?? 0) + 1;
|
|
445
|
+
naturalStopEventSequenceBySessionID.set(sessionID, nextSequence);
|
|
446
|
+
}
|
|
447
|
+
isNaturalStopBySessionID.set(sessionID, true);
|
|
448
|
+
naturalStopSignatureBySessionID.set(sessionID, signature);
|
|
449
|
+
isRetryBySessionID.set(sessionID, false);
|
|
450
|
+
retrySignatureBySessionID.delete(sessionID);
|
|
451
|
+
const handle = createSessionReplyHandle(existingHandles);
|
|
452
|
+
existingHandles.add(handle);
|
|
453
|
+
const eventSequence = naturalStopEventSequenceBySessionID.get(sessionID) ?? 1;
|
|
454
|
+
candidates.push({
|
|
455
|
+
idempotencyKey: `natural-stop-${toIdempotencyPart(input.instanceID)}-${toIdempotencyPart(sessionID)}-${eventSequence}`,
|
|
456
|
+
kind: "naturalStop",
|
|
457
|
+
createdAt: Date.now(),
|
|
458
|
+
sessionID,
|
|
459
|
+
handle,
|
|
460
|
+
replyTarget: {
|
|
461
|
+
instanceID: input.instanceID,
|
|
462
|
+
sessionID,
|
|
463
|
+
},
|
|
464
|
+
redactedSummary: deriveStatusSummary(status),
|
|
465
|
+
severityAdvice: deriveNaturalStopSeverity(status),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
isRetryBySessionID.set(sessionID, false);
|
|
470
|
+
retrySignatureBySessionID.delete(sessionID);
|
|
471
|
+
isNaturalStopBySessionID.set(sessionID, false);
|
|
472
|
+
naturalStopSignatureBySessionID.delete(sessionID);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
for (const knownSessionID of isRetryBySessionID.keys()) {
|
|
476
|
+
if (seenSessionIDs.has(knownSessionID)) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
isRetryBySessionID.set(knownSessionID, false);
|
|
480
|
+
retrySignatureBySessionID.delete(knownSessionID);
|
|
481
|
+
}
|
|
482
|
+
for (const knownSessionID of isNaturalStopBySessionID.keys()) {
|
|
483
|
+
if (seenSessionIDs.has(knownSessionID)) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
isNaturalStopBySessionID.set(knownSessionID, false);
|
|
487
|
+
naturalStopSignatureBySessionID.delete(knownSessionID);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
candidates,
|
|
492
|
+
authoritative,
|
|
493
|
+
};
|
|
494
|
+
};
|
|
495
|
+
const collectNotificationCandidates = async () => {
|
|
496
|
+
const snapshot = await collectNotificationCandidateSnapshot();
|
|
497
|
+
return snapshot.candidates;
|
|
498
|
+
};
|
|
499
|
+
const resyncBrokerState = async (options = {}) => {
|
|
500
|
+
const reason = options.reason ?? "manual";
|
|
501
|
+
const startedAt = Date.now();
|
|
502
|
+
try {
|
|
503
|
+
return await collectStatusSnapshot();
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
await Promise.resolve(input.onDiagnosticEvent?.({
|
|
507
|
+
type: "bridgeResyncFailed",
|
|
508
|
+
code: "bridgeResyncFailed",
|
|
509
|
+
instanceID: input.instanceID,
|
|
510
|
+
reason,
|
|
511
|
+
durationMs: Date.now() - startedAt,
|
|
512
|
+
error: toDiagnosticErrorMessage(error),
|
|
513
|
+
})).catch(() => { });
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
const handleBrokerEnvelope = async (envelope) => {
|
|
518
|
+
if (envelope.type === "replyQuestion") {
|
|
519
|
+
const payload = envelope.payload;
|
|
520
|
+
if (!input.client.question.reply) {
|
|
521
|
+
return {
|
|
522
|
+
mutationId: payload.mutationId,
|
|
523
|
+
ok: false,
|
|
524
|
+
errorMessage: "question.reply unavailable",
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
const response = await input.client.question.reply({
|
|
528
|
+
requestID: payload.requestID,
|
|
529
|
+
answers: payload.answers,
|
|
530
|
+
});
|
|
531
|
+
return normalizeReplyMutationResult(payload.mutationId, response);
|
|
532
|
+
}
|
|
533
|
+
if (envelope.type === "replyPermission") {
|
|
534
|
+
const payload = envelope.payload;
|
|
535
|
+
if (!input.client.permission.reply) {
|
|
536
|
+
return {
|
|
537
|
+
mutationId: payload.mutationId,
|
|
538
|
+
ok: false,
|
|
539
|
+
errorMessage: "permission.reply unavailable",
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const response = await input.client.permission.reply({
|
|
543
|
+
requestID: payload.requestID,
|
|
544
|
+
reply: payload.reply,
|
|
545
|
+
...(payload.message ? { message: payload.message } : {}),
|
|
546
|
+
});
|
|
547
|
+
return normalizeReplyMutationResult(payload.mutationId, response);
|
|
548
|
+
}
|
|
549
|
+
if (envelope.type === "replyNaturalStop") {
|
|
550
|
+
const payload = envelope.payload;
|
|
551
|
+
if (!input.client.session.reply) {
|
|
552
|
+
return {
|
|
553
|
+
mutationId: payload.mutationId,
|
|
554
|
+
ok: false,
|
|
555
|
+
errorMessage: "session.reply unavailable",
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const response = await input.client.session.reply({
|
|
559
|
+
sessionID: payload.sessionID,
|
|
560
|
+
text: payload.text,
|
|
561
|
+
});
|
|
562
|
+
return normalizeReplyMutationResult(payload.mutationId, response);
|
|
563
|
+
}
|
|
564
|
+
return null;
|
|
565
|
+
};
|
|
566
|
+
return {
|
|
567
|
+
collectStatusSnapshot,
|
|
568
|
+
collectNotificationCandidates,
|
|
569
|
+
collectNotificationCandidateSnapshot,
|
|
570
|
+
resyncBrokerState,
|
|
571
|
+
handleBrokerEnvelope,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
export async function createWechatBridgeLifecycle(input, deps = {}) {
|
|
575
|
+
if (input.statusCollectionEnabled !== true) {
|
|
576
|
+
return {
|
|
577
|
+
close: async () => { },
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
const connectOrSpawnBrokerImpl = deps.connectOrSpawnBrokerImpl ?? connectOrSpawnBroker;
|
|
581
|
+
const connectImpl = deps.connectImpl ?? connect;
|
|
582
|
+
const setIntervalImpl = deps.setIntervalImpl ?? setInterval;
|
|
583
|
+
const clearIntervalImpl = deps.clearIntervalImpl ?? clearInterval;
|
|
584
|
+
const directory = toDirectory(input.directory);
|
|
585
|
+
const projectName = toProjectName(input.project);
|
|
586
|
+
const instanceID = PROCESS_INSTANCE_ID;
|
|
587
|
+
const bridge = createWechatBridge({
|
|
588
|
+
instanceID,
|
|
589
|
+
instanceName: toInstanceName(projectName, directory),
|
|
590
|
+
pid: process.pid,
|
|
591
|
+
projectName,
|
|
592
|
+
directory,
|
|
593
|
+
client: input.client,
|
|
594
|
+
getActiveSessionID: input.getActiveSessionID,
|
|
595
|
+
onDiagnosticEvent: createWechatBridgeDiagnosticsWriter(),
|
|
596
|
+
onFallbackToast: input.onFallbackToast,
|
|
597
|
+
});
|
|
598
|
+
let brokerClient;
|
|
599
|
+
const instanceIncarnation = randomUUID();
|
|
600
|
+
let lastSeenBrokerSeq = 0;
|
|
601
|
+
let nextEventSeq = 0;
|
|
602
|
+
let lastSentEventSeq = 0;
|
|
603
|
+
let lastAckedEventSeq = 0;
|
|
604
|
+
let stagedRegisterFullSyncCandidates = null;
|
|
605
|
+
const bridgeEventLog = [];
|
|
606
|
+
let lastSteadyStateSessionSignature = null;
|
|
607
|
+
let lastSteadyStateCandidateSignature = null;
|
|
608
|
+
let lastQuestionCandidates = new Map();
|
|
609
|
+
let lastPermissionCandidates = new Map();
|
|
610
|
+
let lastNaturalStopCandidates = new Map();
|
|
611
|
+
let steadyStateSyncPromise = null;
|
|
612
|
+
const supportsLiveBrokerClient = (candidate) => {
|
|
613
|
+
if (typeof candidate !== "object" || candidate === null) {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
return typeof candidate.setLiveHandlers === "function"
|
|
617
|
+
&& typeof candidate.registerHello === "function"
|
|
618
|
+
&& typeof candidate.ping === "function";
|
|
619
|
+
};
|
|
620
|
+
const supportsLegacyInjectedBrokerClient = (candidate) => {
|
|
621
|
+
if (typeof candidate !== "object" || candidate === null) {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
return typeof candidate.registerInstance === "function"
|
|
625
|
+
&& typeof candidate.heartbeat === "function"
|
|
626
|
+
&& typeof candidate.close === "function";
|
|
627
|
+
};
|
|
628
|
+
function trimAckedBridgeEvents(ackedEventSeq) {
|
|
629
|
+
if (!Number.isSafeInteger(ackedEventSeq) || ackedEventSeq <= lastAckedEventSeq) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
lastAckedEventSeq = ackedEventSeq;
|
|
633
|
+
const firstUnackedIndex = bridgeEventLog.findIndex((event) => event.eventSeq > ackedEventSeq);
|
|
634
|
+
if (firstUnackedIndex === -1) {
|
|
635
|
+
bridgeEventLog.length = 0;
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
bridgeEventLog.splice(0, firstUnackedIndex);
|
|
639
|
+
}
|
|
640
|
+
function createSequencedEvent(type, payload, options = {}) {
|
|
641
|
+
nextEventSeq += 1;
|
|
642
|
+
return {
|
|
643
|
+
type,
|
|
644
|
+
eventSeq: nextEventSeq,
|
|
645
|
+
instanceIncarnation,
|
|
646
|
+
payload: {
|
|
647
|
+
instanceID,
|
|
648
|
+
...payload,
|
|
649
|
+
},
|
|
650
|
+
...(options.controlId ? { controlId: options.controlId } : {}),
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
async function sendSequencedEvent(event, options = {}) {
|
|
654
|
+
if (options.persist !== false) {
|
|
655
|
+
bridgeEventLog.push(event);
|
|
656
|
+
}
|
|
657
|
+
lastSentEventSeq = Math.max(lastSentEventSeq, event.eventSeq);
|
|
658
|
+
const ack = await brokerClient.sendBridgeEvent(event, {
|
|
659
|
+
instanceID,
|
|
660
|
+
...(options.controlId ?? event.controlId ? { controlId: options.controlId ?? event.controlId } : {}),
|
|
661
|
+
});
|
|
662
|
+
trimAckedBridgeEvents(ack.ackedEventSeq);
|
|
663
|
+
}
|
|
664
|
+
function toCandidateEvent(candidate, controlId) {
|
|
665
|
+
if (candidate.kind === "question") {
|
|
666
|
+
return createSequencedEvent("questionOpened", {
|
|
667
|
+
idempotencyKey: candidate.idempotencyKey,
|
|
668
|
+
requestID: candidate.requestID,
|
|
669
|
+
routeKey: candidate.routeKey,
|
|
670
|
+
handle: candidate.handle,
|
|
671
|
+
...(candidate.scopeKey ? { scopeKey: candidate.scopeKey } : {}),
|
|
672
|
+
...(candidate.wechatAccountId ? { wechatAccountId: candidate.wechatAccountId } : {}),
|
|
673
|
+
...(candidate.userId ? { userId: candidate.userId } : {}),
|
|
674
|
+
createdAt: candidate.createdAt,
|
|
675
|
+
updatedAt: candidate.createdAt,
|
|
676
|
+
...(candidate.prompt ? { prompt: candidate.prompt } : {}),
|
|
677
|
+
}, { controlId });
|
|
678
|
+
}
|
|
679
|
+
if (candidate.kind === "permission") {
|
|
680
|
+
return createSequencedEvent("permissionOpened", {
|
|
681
|
+
idempotencyKey: candidate.idempotencyKey,
|
|
682
|
+
requestID: candidate.requestID,
|
|
683
|
+
routeKey: candidate.routeKey,
|
|
684
|
+
handle: candidate.handle,
|
|
685
|
+
...(candidate.scopeKey ? { scopeKey: candidate.scopeKey } : {}),
|
|
686
|
+
...(candidate.wechatAccountId ? { wechatAccountId: candidate.wechatAccountId } : {}),
|
|
687
|
+
...(candidate.userId ? { userId: candidate.userId } : {}),
|
|
688
|
+
createdAt: candidate.createdAt,
|
|
689
|
+
updatedAt: candidate.createdAt,
|
|
690
|
+
...(candidate.prompt ? { prompt: candidate.prompt } : {}),
|
|
691
|
+
}, { controlId });
|
|
692
|
+
}
|
|
693
|
+
if (candidate.kind === "naturalStop") {
|
|
694
|
+
return createSequencedEvent("naturalStopOpened", {
|
|
695
|
+
idempotencyKey: candidate.idempotencyKey,
|
|
696
|
+
sessionID: candidate.sessionID,
|
|
697
|
+
handle: candidate.handle,
|
|
698
|
+
replyTarget: candidate.replyTarget,
|
|
699
|
+
redactedSummary: candidate.redactedSummary,
|
|
700
|
+
severityAdvice: candidate.severityAdvice,
|
|
701
|
+
createdAt: candidate.createdAt,
|
|
702
|
+
updatedAt: candidate.createdAt,
|
|
703
|
+
}, { controlId });
|
|
704
|
+
}
|
|
705
|
+
if (candidate.kind === "sessionError") {
|
|
706
|
+
return createSequencedEvent("retryErrorUpdated", {
|
|
707
|
+
idempotencyKey: candidate.idempotencyKey,
|
|
708
|
+
sessionID: candidate.sessionID,
|
|
709
|
+
action: candidate.action,
|
|
710
|
+
redactedSummary: candidate.redactedSummary,
|
|
711
|
+
severityAdvice: candidate.severityAdvice,
|
|
712
|
+
createdAt: candidate.createdAt,
|
|
713
|
+
updatedAt: candidate.createdAt,
|
|
714
|
+
}, { controlId });
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
async function handleReplayControl(control) {
|
|
719
|
+
const payload = control.payload;
|
|
720
|
+
const fromEventSeq = typeof payload.fromEventSeq === "number" ? payload.fromEventSeq : undefined;
|
|
721
|
+
const toEventSeq = typeof payload.toEventSeq === "number" ? payload.toEventSeq : undefined;
|
|
722
|
+
if (fromEventSeq === undefined || toEventSeq === undefined) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
for (const event of bridgeEventLog) {
|
|
726
|
+
if (event.eventSeq < fromEventSeq || event.eventSeq > toEventSeq) {
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
await sendSequencedEvent(event, {
|
|
730
|
+
persist: false,
|
|
731
|
+
controlId: control.controlId,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async function handleFullSyncControl(control) {
|
|
736
|
+
const snapshot = await bridge.collectStatusSnapshot();
|
|
737
|
+
await sendSequencedEvent(createSequencedEvent("instanceOnline", {
|
|
738
|
+
connectedAt: Date.now(),
|
|
739
|
+
pid: process.pid,
|
|
740
|
+
displayName: toInstanceName(projectName, directory),
|
|
741
|
+
projectDir: directory,
|
|
742
|
+
}, { controlId: control.controlId }));
|
|
743
|
+
for (const session of snapshot.sessions) {
|
|
744
|
+
await sendSequencedEvent(createSequencedEvent("sessionSnapshotChanged", {
|
|
745
|
+
sessionID: session.sessionID,
|
|
746
|
+
...(isNonEmptyString(session.parentID) ? { parentID: session.parentID } : {}),
|
|
747
|
+
title: session.title,
|
|
748
|
+
directory: session.directory,
|
|
749
|
+
updatedAt: session.updatedAt,
|
|
750
|
+
status: session.status,
|
|
751
|
+
pendingQuestionCount: session.pendingQuestionCount,
|
|
752
|
+
pendingPermissionCount: session.pendingPermissionCount,
|
|
753
|
+
todoSummary: session.todoSummary,
|
|
754
|
+
highlights: session.highlights,
|
|
755
|
+
...(session.unavailable ? { unavailable: session.unavailable } : {}),
|
|
756
|
+
...(session.todoItems ? { todoItems: session.todoItems } : {}),
|
|
757
|
+
...(session.questionHighlights ? { questionHighlights: session.questionHighlights } : {}),
|
|
758
|
+
}, { controlId: control.controlId }));
|
|
759
|
+
}
|
|
760
|
+
const candidateSnapshot = stagedRegisterFullSyncCandidates
|
|
761
|
+
?? await (bridge.collectNotificationCandidateSnapshot
|
|
762
|
+
? bridge.collectNotificationCandidateSnapshot()
|
|
763
|
+
: Promise.resolve({
|
|
764
|
+
candidates: await bridge.collectNotificationCandidates(),
|
|
765
|
+
authoritative: true,
|
|
766
|
+
}));
|
|
767
|
+
const candidates = candidateSnapshot.candidates;
|
|
768
|
+
stagedRegisterFullSyncCandidates = null;
|
|
769
|
+
lastSteadyStateSessionSignature = stableBridgeSignature(snapshot.sessions);
|
|
770
|
+
if (candidateSnapshot.authoritative) {
|
|
771
|
+
lastSteadyStateCandidateSignature = stableBridgeSignature(candidates);
|
|
772
|
+
lastQuestionCandidates = indexRequestCandidates(candidates, "question");
|
|
773
|
+
lastPermissionCandidates = indexRequestCandidates(candidates, "permission");
|
|
774
|
+
lastNaturalStopCandidates = indexNaturalStopCandidates(candidates);
|
|
775
|
+
for (const candidate of candidates) {
|
|
776
|
+
const event = toCandidateEvent(candidate, control.controlId);
|
|
777
|
+
if (!event) {
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
await sendSequencedEvent(event);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
await sendSequencedEvent(createSequencedEvent("fullSyncCompleted", {
|
|
784
|
+
controlId: control.controlId,
|
|
785
|
+
}, { controlId: control.controlId }));
|
|
786
|
+
}
|
|
787
|
+
async function syncSteadyStateContentIfChanged() {
|
|
788
|
+
if (steadyStateSyncPromise) {
|
|
789
|
+
return steadyStateSyncPromise;
|
|
790
|
+
}
|
|
791
|
+
steadyStateSyncPromise = (async () => {
|
|
792
|
+
const snapshot = await bridge.collectStatusSnapshot();
|
|
793
|
+
const sessionSignature = stableBridgeSignature(snapshot.sessions);
|
|
794
|
+
if (sessionSignature !== lastSteadyStateSessionSignature) {
|
|
795
|
+
for (const session of snapshot.sessions) {
|
|
796
|
+
await sendSequencedEvent(createSequencedEvent("sessionSnapshotChanged", {
|
|
797
|
+
sessionID: session.sessionID,
|
|
798
|
+
...(isNonEmptyString(session.parentID) ? { parentID: session.parentID } : {}),
|
|
799
|
+
title: session.title,
|
|
800
|
+
directory: session.directory,
|
|
801
|
+
updatedAt: session.updatedAt,
|
|
802
|
+
status: session.status,
|
|
803
|
+
pendingQuestionCount: session.pendingQuestionCount,
|
|
804
|
+
pendingPermissionCount: session.pendingPermissionCount,
|
|
805
|
+
todoSummary: session.todoSummary,
|
|
806
|
+
highlights: session.highlights,
|
|
807
|
+
...(session.unavailable ? { unavailable: session.unavailable } : {}),
|
|
808
|
+
...(session.todoItems ? { todoItems: session.todoItems } : {}),
|
|
809
|
+
...(session.questionHighlights ? { questionHighlights: session.questionHighlights } : {}),
|
|
810
|
+
}));
|
|
811
|
+
}
|
|
812
|
+
lastSteadyStateSessionSignature = sessionSignature;
|
|
813
|
+
}
|
|
814
|
+
const candidateSnapshot = await (bridge.collectNotificationCandidateSnapshot
|
|
815
|
+
? bridge.collectNotificationCandidateSnapshot()
|
|
816
|
+
: Promise.resolve({
|
|
817
|
+
candidates: await bridge.collectNotificationCandidates(),
|
|
818
|
+
authoritative: true,
|
|
819
|
+
}));
|
|
820
|
+
if (!candidateSnapshot.authoritative) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const candidates = candidateSnapshot.candidates;
|
|
824
|
+
const candidateSignature = stableBridgeSignature(candidates);
|
|
825
|
+
if (candidateSignature !== lastSteadyStateCandidateSignature) {
|
|
826
|
+
const nextQuestionCandidates = indexRequestCandidates(candidates, "question");
|
|
827
|
+
for (const [routeKey, candidate] of lastQuestionCandidates) {
|
|
828
|
+
if (nextQuestionCandidates.has(routeKey)) {
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
await sendSequencedEvent(createSequencedEvent("questionClosed", {
|
|
832
|
+
routeKey,
|
|
833
|
+
handle: candidate.handle,
|
|
834
|
+
reason: "answered",
|
|
835
|
+
}));
|
|
836
|
+
}
|
|
837
|
+
const nextPermissionCandidates = indexRequestCandidates(candidates, "permission");
|
|
838
|
+
for (const [routeKey, candidate] of lastPermissionCandidates) {
|
|
839
|
+
if (nextPermissionCandidates.has(routeKey)) {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
await sendSequencedEvent(createSequencedEvent("permissionClosed", {
|
|
843
|
+
routeKey,
|
|
844
|
+
handle: candidate.handle,
|
|
845
|
+
reason: "handled",
|
|
846
|
+
}));
|
|
847
|
+
}
|
|
848
|
+
const nextNaturalStopCandidates = indexNaturalStopCandidates(candidates);
|
|
849
|
+
for (const [handle, candidate] of lastNaturalStopCandidates) {
|
|
850
|
+
if (nextNaturalStopCandidates.has(handle)) {
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
await sendSequencedEvent(createSequencedEvent("naturalStopClosed", {
|
|
854
|
+
handle,
|
|
855
|
+
sessionID: candidate.sessionID,
|
|
856
|
+
reason: "continued",
|
|
857
|
+
}));
|
|
858
|
+
}
|
|
859
|
+
for (const candidate of candidates) {
|
|
860
|
+
const event = toCandidateEvent(candidate);
|
|
861
|
+
if (!event) {
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
await sendSequencedEvent(event);
|
|
865
|
+
}
|
|
866
|
+
lastQuestionCandidates = nextQuestionCandidates;
|
|
867
|
+
lastPermissionCandidates = nextPermissionCandidates;
|
|
868
|
+
lastNaturalStopCandidates = nextNaturalStopCandidates;
|
|
869
|
+
lastSteadyStateCandidateSignature = candidateSignature;
|
|
870
|
+
}
|
|
871
|
+
})().finally(() => {
|
|
872
|
+
steadyStateSyncPromise = null;
|
|
873
|
+
});
|
|
874
|
+
return steadyStateSyncPromise;
|
|
875
|
+
}
|
|
876
|
+
async function handleBrokerControl(control) {
|
|
877
|
+
lastSeenBrokerSeq = Math.max(lastSeenBrokerSeq, control.brokerSeq);
|
|
878
|
+
if (control.type === "requestReplay") {
|
|
879
|
+
await handleReplayControl(control);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
await handleFullSyncControl(control);
|
|
883
|
+
}
|
|
884
|
+
async function handleBrokerCommand(command) {
|
|
885
|
+
lastSeenBrokerSeq = Math.max(lastSeenBrokerSeq, command.brokerSeq);
|
|
886
|
+
if (!bridge.handleBrokerEnvelope) {
|
|
887
|
+
await sendSequencedEvent(createSequencedEvent("commandResult", {
|
|
888
|
+
commandId: command.commandId,
|
|
889
|
+
status: "failed",
|
|
890
|
+
completedAt: Date.now(),
|
|
891
|
+
failure: {
|
|
892
|
+
message: `${command.type} unavailable`,
|
|
893
|
+
},
|
|
894
|
+
}));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
await sendSequencedEvent(createSequencedEvent("commandAccepted", {
|
|
898
|
+
commandId: command.commandId,
|
|
899
|
+
acceptedAt: Date.now(),
|
|
900
|
+
}));
|
|
901
|
+
let result = null;
|
|
902
|
+
try {
|
|
903
|
+
result = await bridge.handleBrokerEnvelope({
|
|
904
|
+
id: command.commandId,
|
|
905
|
+
type: command.type,
|
|
906
|
+
payload: command.payload,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
await sendSequencedEvent(createSequencedEvent("commandResult", {
|
|
911
|
+
commandId: command.commandId,
|
|
912
|
+
status: "failed",
|
|
913
|
+
completedAt: Date.now(),
|
|
914
|
+
failure: {
|
|
915
|
+
message: toDiagnosticErrorMessage(error),
|
|
916
|
+
},
|
|
917
|
+
}));
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const succeeded = result?.ok === true;
|
|
921
|
+
await sendSequencedEvent(createSequencedEvent("commandResult", {
|
|
922
|
+
commandId: command.commandId,
|
|
923
|
+
status: succeeded ? "completed" : "failed",
|
|
924
|
+
completedAt: Date.now(),
|
|
925
|
+
...(succeeded
|
|
926
|
+
? {}
|
|
927
|
+
: {
|
|
928
|
+
failure: {
|
|
929
|
+
message: result?.errorMessage ?? `${command.type} failed`,
|
|
930
|
+
},
|
|
931
|
+
}),
|
|
932
|
+
}));
|
|
933
|
+
}
|
|
934
|
+
async function processRegisterResult(result) {
|
|
935
|
+
lastSeenBrokerSeq = Math.max(lastSeenBrokerSeq, result.ack.brokerSeq);
|
|
936
|
+
if (result.control) {
|
|
937
|
+
await handleBrokerControl(result.control);
|
|
938
|
+
}
|
|
939
|
+
for (const command of result.pendingCommands) {
|
|
940
|
+
await handleBrokerCommand(command);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
async function registerCurrentBrokerClient() {
|
|
944
|
+
try {
|
|
945
|
+
const currentBrokerClient = brokerClient;
|
|
946
|
+
if (supportsLiveBrokerClient(currentBrokerClient)) {
|
|
947
|
+
brokerClient.setLiveHandlers({
|
|
948
|
+
onBrokerControl: (control) => handleBrokerControl(control),
|
|
949
|
+
onBrokerCommand: (command) => handleBrokerCommand(command),
|
|
950
|
+
});
|
|
951
|
+
const registerResult = await brokerClient.registerHello({
|
|
952
|
+
protocolVersion: DEFAULT_BRIDGE_PROTOCOL_VERSION,
|
|
953
|
+
stateGeneration: DEFAULT_BRIDGE_STATE_GENERATION,
|
|
954
|
+
instanceID,
|
|
955
|
+
instanceIncarnation,
|
|
956
|
+
lastSeenBrokerSeq,
|
|
957
|
+
lastSentEventSeq,
|
|
958
|
+
});
|
|
959
|
+
stagedRegisterFullSyncCandidates = registerResult.control?.type === "requestFullSync"
|
|
960
|
+
? await (bridge.collectNotificationCandidateSnapshot
|
|
961
|
+
? bridge.collectNotificationCandidateSnapshot()
|
|
962
|
+
: Promise.resolve({
|
|
963
|
+
candidates: await bridge.collectNotificationCandidates(),
|
|
964
|
+
authoritative: true,
|
|
965
|
+
}))
|
|
966
|
+
: null;
|
|
967
|
+
await processRegisterResult(registerResult);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (supportsLegacyInjectedBrokerClient(currentBrokerClient)) {
|
|
971
|
+
await currentBrokerClient.registerInstance({
|
|
972
|
+
instanceID,
|
|
973
|
+
pid: process.pid,
|
|
974
|
+
displayName: toInstanceName(projectName, directory),
|
|
975
|
+
projectDir: directory,
|
|
976
|
+
});
|
|
977
|
+
stagedRegisterFullSyncCandidates = null;
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
throw new TypeError("broker client does not support live registration");
|
|
981
|
+
}
|
|
982
|
+
catch (error) {
|
|
983
|
+
await brokerClient.close().catch(() => { });
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (input.initialBrokerPromise) {
|
|
988
|
+
try {
|
|
989
|
+
const initialBroker = await input.initialBrokerPromise;
|
|
990
|
+
brokerClient = await connectImpl(initialBroker.endpoint);
|
|
991
|
+
await registerCurrentBrokerClient();
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
const fallbackBroker = await connectOrSpawnBrokerImpl();
|
|
995
|
+
brokerClient = await connectImpl(fallbackBroker.endpoint);
|
|
996
|
+
await registerCurrentBrokerClient();
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
else {
|
|
1000
|
+
const broker = await connectOrSpawnBrokerImpl();
|
|
1001
|
+
brokerClient = await connectImpl(broker.endpoint);
|
|
1002
|
+
await registerCurrentBrokerClient();
|
|
1003
|
+
}
|
|
1004
|
+
const heartbeatIntervalMs = typeof input.heartbeatIntervalMs === "number" && Number.isFinite(input.heartbeatIntervalMs)
|
|
1005
|
+
? Math.max(1_000, Math.floor(input.heartbeatIntervalMs))
|
|
1006
|
+
: DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
1007
|
+
let reconnectPromise = null;
|
|
1008
|
+
let closed = false;
|
|
1009
|
+
const reconnectBrokerClient = async () => {
|
|
1010
|
+
if (closed) {
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (reconnectPromise) {
|
|
1014
|
+
return reconnectPromise;
|
|
1015
|
+
}
|
|
1016
|
+
reconnectPromise = (async () => {
|
|
1017
|
+
const previousBrokerClient = brokerClient;
|
|
1018
|
+
await previousBrokerClient.close().catch(() => { });
|
|
1019
|
+
const nextBroker = await connectOrSpawnBrokerImpl();
|
|
1020
|
+
const nextBrokerClient = await connectImpl(nextBroker.endpoint);
|
|
1021
|
+
brokerClient = nextBrokerClient;
|
|
1022
|
+
try {
|
|
1023
|
+
await registerCurrentBrokerClient();
|
|
1024
|
+
}
|
|
1025
|
+
catch (error) {
|
|
1026
|
+
await nextBrokerClient.close().catch(() => { });
|
|
1027
|
+
throw error;
|
|
1028
|
+
}
|
|
1029
|
+
})().finally(() => {
|
|
1030
|
+
reconnectPromise = null;
|
|
1031
|
+
});
|
|
1032
|
+
return reconnectPromise;
|
|
1033
|
+
};
|
|
1034
|
+
const timer = setIntervalImpl(() => {
|
|
1035
|
+
if (closed) {
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const currentBrokerClient = brokerClient;
|
|
1039
|
+
const heartbeatPromise = supportsLiveBrokerClient(currentBrokerClient)
|
|
1040
|
+
? currentBrokerClient.ping()
|
|
1041
|
+
: supportsLegacyInjectedBrokerClient(currentBrokerClient)
|
|
1042
|
+
? currentBrokerClient.heartbeat()
|
|
1043
|
+
: Promise.reject(new TypeError("broker client does not support keepalive"));
|
|
1044
|
+
void heartbeatPromise
|
|
1045
|
+
.then(() => syncSteadyStateContentIfChanged())
|
|
1046
|
+
.catch(() => reconnectBrokerClient().catch(() => { }));
|
|
1047
|
+
}, heartbeatIntervalMs);
|
|
1048
|
+
return {
|
|
1049
|
+
close: async () => {
|
|
1050
|
+
if (closed) {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
closed = true;
|
|
1054
|
+
clearIntervalImpl(timer);
|
|
1055
|
+
await reconnectPromise?.catch(() => { });
|
|
1056
|
+
await brokerClient.close().catch(() => { });
|
|
1057
|
+
},
|
|
1058
|
+
};
|
|
1059
|
+
}
|