ai-task-board-bridge 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -0
- package/dist/activity-sanitizer.d.ts +5 -0
- package/dist/activity-sanitizer.d.ts.map +1 -0
- package/dist/activity-sanitizer.js +55 -0
- package/dist/activity-sanitizer.js.map +1 -0
- package/dist/app-server-client.d.ts +328 -0
- package/dist/app-server-client.d.ts.map +1 -0
- package/dist/app-server-client.js +524 -0
- package/dist/app-server-client.js.map +1 -0
- package/dist/bridge.d.ts +139 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +2769 -0
- package/dist/bridge.js.map +1 -0
- package/dist/claim-retry.d.ts +10 -0
- package/dist/claim-retry.d.ts.map +1 -0
- package/dist/claim-retry.js +19 -0
- package/dist/claim-retry.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +92 -0
- package/dist/cli.js.map +1 -0
- package/dist/history-sync.d.ts +120 -0
- package/dist/history-sync.d.ts.map +1 -0
- package/dist/history-sync.js +718 -0
- package/dist/history-sync.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/setup.d.ts +32 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +822 -0
- package/dist/setup.js.map +1 -0
- package/dist/wake-client.d.ts +35 -0
- package/dist/wake-client.d.ts.map +1 -0
- package/dist/wake-client.js +219 -0
- package/dist/wake-client.js.map +1 -0
- package/dist/working-directories.d.ts +24 -0
- package/dist/working-directories.d.ts.map +1 -0
- package/dist/working-directories.js +158 -0
- package/dist/working-directories.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { redactHarnessText } from "./activity-sanitizer.js";
|
|
3
|
+
import { WakeLatch } from "./wake-client.js";
|
|
4
|
+
export const HISTORY_CONTENT_LIMIT = 50_000;
|
|
5
|
+
export const HISTORY_IMPORT_ITEM_LIMIT = 100;
|
|
6
|
+
export const HISTORY_IMPORT_BODY_LIMIT_BYTES = 512 * 1024;
|
|
7
|
+
export const HISTORY_SCAN_TURN_LIMIT = 500;
|
|
8
|
+
export const HISTORY_ITEMS_PER_TURN_LIMIT = 10_000;
|
|
9
|
+
export const HISTORY_ACTIVITIES_PER_SCAN_LIMIT = 500;
|
|
10
|
+
export const HISTORY_TARGET_QUEUE_LIMIT = 500;
|
|
11
|
+
const HISTORY_SCAN_PAGE_SIZE = 50;
|
|
12
|
+
const HISTORY_ITEM_PAGE_SIZE = 100;
|
|
13
|
+
const HISTORY_ITEM_PAGE_LIMIT = 200;
|
|
14
|
+
const HISTORY_TARGETS_PER_SLICE = 2;
|
|
15
|
+
const HISTORY_SLICE_DELAY_MS = 10_000;
|
|
16
|
+
const HISTORY_DISABLED_WAIT_MS = 60_000;
|
|
17
|
+
const HISTORY_TARGET_TIMEOUT_MS = 20_000;
|
|
18
|
+
const HISTORY_FAILURE_REPORT_TIMEOUT_MS = 3_000;
|
|
19
|
+
const HISTORY_FAILURE_RETRY_DELAYS_MS = [60_000, 5 * 60_000];
|
|
20
|
+
const HISTORY_SOURCE_ORDER_STRIDE = 512;
|
|
21
|
+
const HISTORY_LEGACY_ITEMS_PER_TURN_LIMIT = 500;
|
|
22
|
+
const HISTORY_SOURCE_ORDER_MAX_DISCRIMINATOR = Math.floor((Number.MAX_SAFE_INTEGER - (HISTORY_SOURCE_ORDER_STRIDE - 1)) /
|
|
23
|
+
HISTORY_SOURCE_ORDER_STRIDE);
|
|
24
|
+
const HISTORY_TRUNCATION_SUFFIX = "\n…[历史内容已截断]";
|
|
25
|
+
const HISTORY_SAFETY_CAP_CURSOR = "local-safety-cap";
|
|
26
|
+
const APP_SERVER_PROTOCOL = "codex-app-server/v1";
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function nonEmptyString(value) {
|
|
31
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
32
|
+
}
|
|
33
|
+
function protocolIdentifier(value) {
|
|
34
|
+
return typeof value === "string" &&
|
|
35
|
+
value.length >= 1 &&
|
|
36
|
+
value.length <= 500 &&
|
|
37
|
+
value === value.trim()
|
|
38
|
+
? value
|
|
39
|
+
: null;
|
|
40
|
+
}
|
|
41
|
+
function secondsValue(value) {
|
|
42
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
43
|
+
? value
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
function uuidV7Milliseconds(value) {
|
|
47
|
+
const match = /^([0-9a-f]{8})-([0-9a-f]{4})-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.exec(value);
|
|
48
|
+
if (!match)
|
|
49
|
+
return null;
|
|
50
|
+
const milliseconds = Number.parseInt(`${match[1]}${match[2]}`, 16);
|
|
51
|
+
return Number.isSafeInteger(milliseconds) ? milliseconds : null;
|
|
52
|
+
}
|
|
53
|
+
function stableTurnOccurredAt(turn, threadCreatedAt) {
|
|
54
|
+
const timestampMilliseconds = (secondsValue(turn.startedAt) ?? secondsValue(turn.completedAt)) !== null
|
|
55
|
+
? (secondsValue(turn.startedAt) ?? secondsValue(turn.completedAt) ?? 0) *
|
|
56
|
+
1_000
|
|
57
|
+
: (uuidV7Milliseconds(turn.id) ??
|
|
58
|
+
((threadCreatedAt ?? 0) * 1_000));
|
|
59
|
+
const bounded = Math.min(8_640_000_000_000_000, Math.max(0, Math.trunc(timestampMilliseconds)));
|
|
60
|
+
return new Date(bounded).toISOString();
|
|
61
|
+
}
|
|
62
|
+
/** Redacts known secret forms and truncates without splitting a UTF-16 pair. */
|
|
63
|
+
export function sanitizeHistoryContent(value) {
|
|
64
|
+
const redacted = redactHarnessText(value, Number.MAX_SAFE_INTEGER);
|
|
65
|
+
if (redacted.length <= HISTORY_CONTENT_LIMIT)
|
|
66
|
+
return redacted;
|
|
67
|
+
let end = HISTORY_CONTENT_LIMIT - HISTORY_TRUNCATION_SUFFIX.length;
|
|
68
|
+
if (/^[\uD800-\uDBFF]$/.test(redacted[end - 1] ?? ""))
|
|
69
|
+
end -= 1;
|
|
70
|
+
return `${redacted.slice(0, Math.max(0, end))}${HISTORY_TRUNCATION_SUFFIX}`;
|
|
71
|
+
}
|
|
72
|
+
function stableExternalRef(threadId, turnId, itemId) {
|
|
73
|
+
const direct = `codex-history:${threadId}:${turnId}:${itemId}`;
|
|
74
|
+
if (direct.length <= 500)
|
|
75
|
+
return direct;
|
|
76
|
+
const digest = createHash("sha256")
|
|
77
|
+
.update(threadId)
|
|
78
|
+
.update("\0")
|
|
79
|
+
.update(turnId)
|
|
80
|
+
.update("\0")
|
|
81
|
+
.update(itemId)
|
|
82
|
+
.digest("hex");
|
|
83
|
+
return `codex-history:sha256:${digest}`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Give every turn a stable, well-spaced tie-break range. Codex timestamps are
|
|
87
|
+
* expressed in seconds, so two turns can otherwise interleave user/reasoning/
|
|
88
|
+
* answer items when they share one occurred_at value. A 44-bit discriminator
|
|
89
|
+
* keeps the complete range inside JavaScript's safe-integer boundary.
|
|
90
|
+
*/
|
|
91
|
+
function stableTurnSourceOrderBase(turnId) {
|
|
92
|
+
const discriminator = createHash("sha256")
|
|
93
|
+
.update("codex-history-source-order\0")
|
|
94
|
+
.update(turnId)
|
|
95
|
+
.digest()
|
|
96
|
+
.readUIntBE(0, 6) % HISTORY_SOURCE_ORDER_MAX_DISCRIMINATOR;
|
|
97
|
+
return discriminator * HISTORY_SOURCE_ORDER_STRIDE;
|
|
98
|
+
}
|
|
99
|
+
function userMessageText(item) {
|
|
100
|
+
if (!Array.isArray(item.content))
|
|
101
|
+
return null;
|
|
102
|
+
const text = item.content
|
|
103
|
+
.flatMap((part) => isRecord(part) && part.type === "text" && typeof part.text === "string"
|
|
104
|
+
? [part.text]
|
|
105
|
+
: [])
|
|
106
|
+
.join("\n");
|
|
107
|
+
if (!text.trim())
|
|
108
|
+
return null;
|
|
109
|
+
return sanitizeHistoryContent(text);
|
|
110
|
+
}
|
|
111
|
+
function reasoningSummary(item) {
|
|
112
|
+
if (!Array.isArray(item.summary))
|
|
113
|
+
return null;
|
|
114
|
+
const text = item.summary
|
|
115
|
+
.filter((part) => typeof part === "string" && Boolean(part.trim()))
|
|
116
|
+
.join("\n\n");
|
|
117
|
+
if (!text.trim())
|
|
118
|
+
return null;
|
|
119
|
+
return sanitizeHistoryContent(text);
|
|
120
|
+
}
|
|
121
|
+
function createHistoryCandidateCollector(activityLimit) {
|
|
122
|
+
return {
|
|
123
|
+
nonFinal: [],
|
|
124
|
+
explicitFinal: { seen: false, candidate: null },
|
|
125
|
+
compatibleFinal: { seen: false, candidate: null },
|
|
126
|
+
activityLimit,
|
|
127
|
+
overflowed: false,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function historyCandidate(item, sourceIndex, kind, content) {
|
|
131
|
+
const itemId = protocolIdentifier(item.id);
|
|
132
|
+
return itemId && content && content.trim()
|
|
133
|
+
? { itemId, kind, content, sourceIndex }
|
|
134
|
+
: null;
|
|
135
|
+
}
|
|
136
|
+
/** Inspect only explicitly whitelisted fields and retain a bounded projection. */
|
|
137
|
+
function observeHistoryItem(collector, item, sourceIndex) {
|
|
138
|
+
if (item.type === "userMessage") {
|
|
139
|
+
if (nonEmptyString(item.clientId))
|
|
140
|
+
return true;
|
|
141
|
+
const candidate = historyCandidate(item, sourceIndex, "user_message", userMessageText(item));
|
|
142
|
+
if (candidate) {
|
|
143
|
+
if (collector.nonFinal.length >= collector.activityLimit) {
|
|
144
|
+
collector.overflowed = true;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
collector.nonFinal.push(candidate);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
if (item.type === "reasoning") {
|
|
153
|
+
const candidate = historyCandidate(item, sourceIndex, "reasoning",
|
|
154
|
+
// Deliberately read only `summary`; raw `content` is never inspected.
|
|
155
|
+
reasoningSummary(item));
|
|
156
|
+
if (candidate) {
|
|
157
|
+
if (collector.nonFinal.length >= collector.activityLimit) {
|
|
158
|
+
collector.overflowed = true;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
collector.nonFinal.push(candidate);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
if (item.type !== "agentMessage")
|
|
167
|
+
return false;
|
|
168
|
+
const phase = item.phase;
|
|
169
|
+
if (phase !== "final_answer" && phase !== undefined && phase !== null) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
const candidate = historyCandidate(item, sourceIndex, "assistant_message", typeof item.text === "string" && item.text.trim()
|
|
173
|
+
? sanitizeHistoryContent(item.text)
|
|
174
|
+
: null);
|
|
175
|
+
const slot = phase === "final_answer"
|
|
176
|
+
? collector.explicitFinal
|
|
177
|
+
: collector.compatibleFinal;
|
|
178
|
+
// Preserve the old mapper's behavior: the last item of the selected phase
|
|
179
|
+
// wins even when its identifier or text is invalid.
|
|
180
|
+
slot.seen = true;
|
|
181
|
+
slot.candidate = candidate;
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
function collectedHistoryCandidates(collector) {
|
|
185
|
+
const final = collector.explicitFinal.seen
|
|
186
|
+
? collector.explicitFinal.candidate
|
|
187
|
+
: collector.compatibleFinal.candidate;
|
|
188
|
+
const candidates = final
|
|
189
|
+
? [...collector.nonFinal, final]
|
|
190
|
+
: [...collector.nonFinal];
|
|
191
|
+
return {
|
|
192
|
+
candidates: candidates.sort((left, right) => left.sourceIndex - right.sourceIndex),
|
|
193
|
+
overflowed: collector.overflowed || candidates.length > collector.activityLimit,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function historyActivitiesFromCandidates(threadId, turn, candidates, threadCreatedAt, denseSourceOrder) {
|
|
197
|
+
const safeThreadId = protocolIdentifier(threadId);
|
|
198
|
+
const safeTurnId = protocolIdentifier(turn.id);
|
|
199
|
+
if (!safeThreadId || !safeTurnId || turn.status !== "completed")
|
|
200
|
+
return [];
|
|
201
|
+
const occurredAt = stableTurnOccurredAt(turn, threadCreatedAt);
|
|
202
|
+
const sourceOrderBase = stableTurnSourceOrderBase(safeTurnId);
|
|
203
|
+
return candidates.map((candidate, index) => ({
|
|
204
|
+
external_ref: stableExternalRef(safeThreadId, safeTurnId, candidate.itemId),
|
|
205
|
+
kind: candidate.kind,
|
|
206
|
+
content: candidate.content,
|
|
207
|
+
occurred_at: occurredAt,
|
|
208
|
+
// Old scans used raw indexes for turns that fit the former 500-item cap.
|
|
209
|
+
// Larger turns were never importable, so a dense ordinal gives their
|
|
210
|
+
// whitelisted projection a stable, non-overlapping safe-integer range.
|
|
211
|
+
source_order: sourceOrderBase + (denseSourceOrder ? index : candidate.sourceIndex),
|
|
212
|
+
data: {
|
|
213
|
+
protocol: APP_SERVER_PROTOCOL,
|
|
214
|
+
thread_id: safeThreadId,
|
|
215
|
+
turn_id: safeTurnId,
|
|
216
|
+
item_id: candidate.itemId,
|
|
217
|
+
},
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
export function hasClientUserMessageId(turn) {
|
|
221
|
+
return (turn.items ?? []).some((item) => item.type === "userMessage" && nonEmptyString(item.clientId) !== null);
|
|
222
|
+
}
|
|
223
|
+
/** History upload fails closed: only an explicit interactive source is safe. */
|
|
224
|
+
export function isInteractiveHistoryThread(thread) {
|
|
225
|
+
const source = thread.source;
|
|
226
|
+
return source === "cli" || source === "vscode";
|
|
227
|
+
}
|
|
228
|
+
export function historicalActivitiesForTurn(threadId, turn, threadCreatedAt = null) {
|
|
229
|
+
if (turn.status !== "completed" || hasClientUserMessageId(turn))
|
|
230
|
+
return [];
|
|
231
|
+
const items = Array.isArray(turn.items) ? turn.items : [];
|
|
232
|
+
const collector = createHistoryCandidateCollector(Number.MAX_SAFE_INTEGER);
|
|
233
|
+
for (const [sourceIndex, item] of items.entries()) {
|
|
234
|
+
observeHistoryItem(collector, item, sourceIndex);
|
|
235
|
+
}
|
|
236
|
+
const { candidates } = collectedHistoryCandidates(collector);
|
|
237
|
+
return historyActivitiesFromCandidates(threadId, turn, candidates, threadCreatedAt, items.length > HISTORY_LEGACY_ITEMS_PER_TURN_LIMIT);
|
|
238
|
+
}
|
|
239
|
+
async function readTurnHistory(appServer, threadId, turn, threadCreatedAt, activityLimit, signal) {
|
|
240
|
+
const collector = createHistoryCandidateCollector(activityLimit);
|
|
241
|
+
const seenCursors = new Set();
|
|
242
|
+
let cursor = null;
|
|
243
|
+
let rawItemCount = 0;
|
|
244
|
+
let matchedItemCount = 0;
|
|
245
|
+
let pageCount = 0;
|
|
246
|
+
do {
|
|
247
|
+
const requestLimit = Math.min(HISTORY_ITEM_PAGE_SIZE, HISTORY_ITEMS_PER_TURN_LIMIT - rawItemCount);
|
|
248
|
+
const page = await appServer.threadItemsList({
|
|
249
|
+
threadId,
|
|
250
|
+
turnId: turn.id,
|
|
251
|
+
cursor,
|
|
252
|
+
limit: requestLimit,
|
|
253
|
+
sortDirection: "asc",
|
|
254
|
+
}, { signal, timeoutMs: 10_000 });
|
|
255
|
+
pageCount += 1;
|
|
256
|
+
if (!Array.isArray(page.data) || page.data.length > requestLimit) {
|
|
257
|
+
throw new Error("Codex thread/items/list 返回无效或超预算 data");
|
|
258
|
+
}
|
|
259
|
+
rawItemCount += page.data.length;
|
|
260
|
+
for (const entry of page.data) {
|
|
261
|
+
if (entry.turnId === turn.id && isRecord(entry.item)) {
|
|
262
|
+
const item = entry.item;
|
|
263
|
+
const boardTurn = observeHistoryItem(collector, item, matchedItemCount);
|
|
264
|
+
matchedItemCount += 1;
|
|
265
|
+
if (boardTurn)
|
|
266
|
+
return { status: "board_turn" };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const nextCursor = nonEmptyString(page.nextCursor);
|
|
270
|
+
if (!nextCursor) {
|
|
271
|
+
cursor = null;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
if (nextCursor.length > 2_000 || seenCursors.has(nextCursor)) {
|
|
275
|
+
throw new Error("Codex thread/items/list 返回无效 cursor");
|
|
276
|
+
}
|
|
277
|
+
if (rawItemCount >= HISTORY_ITEMS_PER_TURN_LIMIT ||
|
|
278
|
+
pageCount >= HISTORY_ITEM_PAGE_LIMIT) {
|
|
279
|
+
return { status: "safety_cap" };
|
|
280
|
+
}
|
|
281
|
+
seenCursors.add(nextCursor);
|
|
282
|
+
cursor = nextCursor;
|
|
283
|
+
} while (!signal.aborted);
|
|
284
|
+
if (signal.aborted)
|
|
285
|
+
throw signal.reason;
|
|
286
|
+
const { candidates, overflowed } = collectedHistoryCandidates(collector);
|
|
287
|
+
if (overflowed)
|
|
288
|
+
return { status: "safety_cap" };
|
|
289
|
+
return {
|
|
290
|
+
status: "accepted",
|
|
291
|
+
activities: historyActivitiesFromCandidates(threadId, turn, candidates, threadCreatedAt, matchedItemCount > HISTORY_LEGACY_ITEMS_PER_TURN_LIMIT),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
export async function scanThreadHistory(options) {
|
|
295
|
+
if (!Number.isInteger(options.turnLimit) || options.turnLimit < 1 || options.turnLimit > 500) {
|
|
296
|
+
throw new RangeError("history turnLimit must be an integer from 1 to 500");
|
|
297
|
+
}
|
|
298
|
+
const accepted = [];
|
|
299
|
+
const seenExternalRefs = new Set();
|
|
300
|
+
const threadCreatedAt = secondsValue(options.thread.createdAt);
|
|
301
|
+
let acceptedActivityCount = 0;
|
|
302
|
+
const seenCursors = new Set();
|
|
303
|
+
let cursor = null;
|
|
304
|
+
let rawScannedTurns = 0;
|
|
305
|
+
let sourceExhausted = false;
|
|
306
|
+
let safetyCapReached = false;
|
|
307
|
+
scanPages: while (!options.signal.aborted &&
|
|
308
|
+
accepted.length < options.turnLimit &&
|
|
309
|
+
rawScannedTurns < HISTORY_SCAN_TURN_LIMIT) {
|
|
310
|
+
const requestLimit = Math.min(HISTORY_SCAN_PAGE_SIZE, options.turnLimit - accepted.length, HISTORY_SCAN_TURN_LIMIT - rawScannedTurns);
|
|
311
|
+
const page = await options.appServer.threadTurnsList({
|
|
312
|
+
threadId: options.thread.id,
|
|
313
|
+
cursor,
|
|
314
|
+
limit: requestLimit,
|
|
315
|
+
sortDirection: "desc",
|
|
316
|
+
itemsView: "notLoaded",
|
|
317
|
+
}, { signal: options.signal, timeoutMs: 10_000 });
|
|
318
|
+
if (!Array.isArray(page.data) || page.data.length > requestLimit) {
|
|
319
|
+
throw new Error("Codex thread/turns/list 返回无效或超预算 data");
|
|
320
|
+
}
|
|
321
|
+
const pageNextCursor = nonEmptyString(page.nextCursor);
|
|
322
|
+
if (pageNextCursor &&
|
|
323
|
+
(pageNextCursor.length > 2_000 || seenCursors.has(pageNextCursor))) {
|
|
324
|
+
throw new Error("Codex thread/turns/list 返回无效 cursor");
|
|
325
|
+
}
|
|
326
|
+
rawScannedTurns += page.data.length;
|
|
327
|
+
for (const candidate of page.data) {
|
|
328
|
+
if (candidate.status !== "completed")
|
|
329
|
+
continue;
|
|
330
|
+
const turn = await readTurnHistory(options.appServer, options.thread.id, candidate, threadCreatedAt, HISTORY_ACTIVITIES_PER_SCAN_LIMIT - acceptedActivityCount, options.signal);
|
|
331
|
+
if (turn.status === "board_turn")
|
|
332
|
+
continue;
|
|
333
|
+
if (turn.status === "safety_cap") {
|
|
334
|
+
safetyCapReached = true;
|
|
335
|
+
cursor = HISTORY_SAFETY_CAP_CURSOR;
|
|
336
|
+
break scanPages;
|
|
337
|
+
}
|
|
338
|
+
const activities = turn.activities;
|
|
339
|
+
for (const activity of activities) {
|
|
340
|
+
if (seenExternalRefs.has(activity.external_ref)) {
|
|
341
|
+
throw new Error("Codex 历史返回重复 item id");
|
|
342
|
+
}
|
|
343
|
+
seenExternalRefs.add(activity.external_ref);
|
|
344
|
+
}
|
|
345
|
+
acceptedActivityCount += activities.length;
|
|
346
|
+
if (acceptedActivityCount > HISTORY_ACTIVITIES_PER_SCAN_LIMIT) {
|
|
347
|
+
throw new Error("Codex 历史活动边界检查失败");
|
|
348
|
+
}
|
|
349
|
+
accepted.push(activities);
|
|
350
|
+
}
|
|
351
|
+
if (!pageNextCursor || page.data.length === 0) {
|
|
352
|
+
cursor = null;
|
|
353
|
+
sourceExhausted = true;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
seenCursors.add(pageNextCursor);
|
|
357
|
+
cursor = pageNextCursor;
|
|
358
|
+
}
|
|
359
|
+
if (options.signal.aborted)
|
|
360
|
+
throw options.signal.reason;
|
|
361
|
+
return {
|
|
362
|
+
items: accepted.reverse().flat(),
|
|
363
|
+
scannedTurns: accepted.length,
|
|
364
|
+
nextCursor: cursor,
|
|
365
|
+
sourceExhausted,
|
|
366
|
+
safetyCapReached,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
export function splitHistoryImportItems(runtimeInstanceId, items, sync) {
|
|
370
|
+
if (items.length === 0)
|
|
371
|
+
return [[]];
|
|
372
|
+
const batches = [];
|
|
373
|
+
let current = [];
|
|
374
|
+
for (const item of items) {
|
|
375
|
+
const candidate = [...current, item];
|
|
376
|
+
const body = {
|
|
377
|
+
runtime_instance_id: runtimeInstanceId,
|
|
378
|
+
// Use the largest encoded sequence so the returned batches remain below
|
|
379
|
+
// the byte ceiling when their real monotonically increasing value is set.
|
|
380
|
+
report_sequence: Number.MAX_SAFE_INTEGER,
|
|
381
|
+
items: candidate,
|
|
382
|
+
sync,
|
|
383
|
+
};
|
|
384
|
+
const bytes = Buffer.byteLength(JSON.stringify(body), "utf8");
|
|
385
|
+
if (current.length > 0 &&
|
|
386
|
+
(candidate.length > HISTORY_IMPORT_ITEM_LIMIT ||
|
|
387
|
+
bytes > HISTORY_IMPORT_BODY_LIMIT_BYTES)) {
|
|
388
|
+
batches.push(current);
|
|
389
|
+
current = [item];
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
current = candidate;
|
|
393
|
+
}
|
|
394
|
+
const singleBytes = Buffer.byteLength(JSON.stringify({
|
|
395
|
+
runtime_instance_id: runtimeInstanceId,
|
|
396
|
+
report_sequence: Number.MAX_SAFE_INTEGER,
|
|
397
|
+
items: current,
|
|
398
|
+
sync,
|
|
399
|
+
}), "utf8");
|
|
400
|
+
if (current.length > HISTORY_IMPORT_ITEM_LIMIT ||
|
|
401
|
+
singleBytes > HISTORY_IMPORT_BODY_LIMIT_BYTES) {
|
|
402
|
+
throw new Error("单条 Codex 历史活动超过批量导入安全上限");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (current.length > 0)
|
|
406
|
+
batches.push(current);
|
|
407
|
+
return batches;
|
|
408
|
+
}
|
|
409
|
+
function abortMessage(error) {
|
|
410
|
+
return error instanceof Error ? error.message : String(error);
|
|
411
|
+
}
|
|
412
|
+
function linkedTimeoutController(parent, milliseconds) {
|
|
413
|
+
const controller = new AbortController();
|
|
414
|
+
const forwardAbort = () => controller.abort(parent.reason);
|
|
415
|
+
parent.addEventListener("abort", forwardAbort, { once: true });
|
|
416
|
+
const timer = setTimeout(() => controller.abort(new Error("Codex 历史同步超过单 thread 时间预算")), milliseconds);
|
|
417
|
+
if (parent.aborted)
|
|
418
|
+
forwardAbort();
|
|
419
|
+
return {
|
|
420
|
+
controller,
|
|
421
|
+
cleanup: () => {
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
parent.removeEventListener("abort", forwardAbort);
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
async function waitUntilAborted(signal) {
|
|
428
|
+
if (signal.aborted)
|
|
429
|
+
return;
|
|
430
|
+
await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
431
|
+
}
|
|
432
|
+
export class HistorySynchronizer {
|
|
433
|
+
options;
|
|
434
|
+
wakeLatch = new WakeLatch();
|
|
435
|
+
targets = new Map();
|
|
436
|
+
queuedSignatures = new Map();
|
|
437
|
+
completedSignatures = new Map();
|
|
438
|
+
failures = new Map();
|
|
439
|
+
queue = [];
|
|
440
|
+
activeController = null;
|
|
441
|
+
activeThreadId = null;
|
|
442
|
+
activeSignature = null;
|
|
443
|
+
runPromise = null;
|
|
444
|
+
configurationGeneration = 0;
|
|
445
|
+
wakeGeneration = 0;
|
|
446
|
+
reportSequence = 0;
|
|
447
|
+
stopped = false;
|
|
448
|
+
constructor(options) {
|
|
449
|
+
this.options = options;
|
|
450
|
+
}
|
|
451
|
+
start(signal) {
|
|
452
|
+
if (this.stopped)
|
|
453
|
+
return Promise.resolve();
|
|
454
|
+
this.runPromise ??= this.run(signal);
|
|
455
|
+
return this.runPromise;
|
|
456
|
+
}
|
|
457
|
+
updateTargets(targets) {
|
|
458
|
+
if (this.stopped)
|
|
459
|
+
return;
|
|
460
|
+
const bounded = targets.slice(0, HISTORY_TARGET_QUEUE_LIMIT);
|
|
461
|
+
const nextIds = new Set(bounded.map((target) => target.thread.id));
|
|
462
|
+
for (const threadId of this.targets.keys()) {
|
|
463
|
+
if (nextIds.has(threadId))
|
|
464
|
+
continue;
|
|
465
|
+
this.targets.delete(threadId);
|
|
466
|
+
this.queuedSignatures.delete(threadId);
|
|
467
|
+
this.completedSignatures.delete(threadId);
|
|
468
|
+
this.failures.delete(threadId);
|
|
469
|
+
}
|
|
470
|
+
if (this.activeThreadId && !nextIds.has(this.activeThreadId)) {
|
|
471
|
+
this.activeController?.abort(new Error("Codex history target was removed"));
|
|
472
|
+
}
|
|
473
|
+
this.queue = this.queue.filter((threadId) => nextIds.has(threadId));
|
|
474
|
+
const configuration = this.options.configuration();
|
|
475
|
+
const now = Date.now();
|
|
476
|
+
for (const target of bounded) {
|
|
477
|
+
const threadId = target.thread.id;
|
|
478
|
+
this.targets.set(threadId, target);
|
|
479
|
+
const signature = this.targetSignature(target, configuration.turnLimit);
|
|
480
|
+
if (this.activeThreadId === threadId) {
|
|
481
|
+
if (this.activeSignature === signature)
|
|
482
|
+
continue;
|
|
483
|
+
this.activeController?.abort(new Error("Codex history target changed while syncing"));
|
|
484
|
+
}
|
|
485
|
+
if (this.queuedSignatures.get(threadId) === signature)
|
|
486
|
+
continue;
|
|
487
|
+
if (this.completedSignatures.get(threadId) === signature)
|
|
488
|
+
continue;
|
|
489
|
+
const failure = this.failures.get(threadId);
|
|
490
|
+
if (failure?.signature !== signature)
|
|
491
|
+
this.failures.delete(threadId);
|
|
492
|
+
if (failure?.signature === signature &&
|
|
493
|
+
(failure.attempts > HISTORY_FAILURE_RETRY_DELAYS_MS.length ||
|
|
494
|
+
failure.retryAt > now)) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
// Replace a stale queued version instead of adding the same thread twice.
|
|
498
|
+
this.queue = this.queue.filter((queuedThreadId) => queuedThreadId !== threadId);
|
|
499
|
+
this.queue.push(threadId);
|
|
500
|
+
this.queuedSignatures.set(threadId, signature);
|
|
501
|
+
}
|
|
502
|
+
this.wakeGeneration += 1;
|
|
503
|
+
this.wakeLatch.wake();
|
|
504
|
+
}
|
|
505
|
+
configurationChanged() {
|
|
506
|
+
if (this.stopped)
|
|
507
|
+
return;
|
|
508
|
+
this.configurationGeneration += 1;
|
|
509
|
+
this.wakeGeneration += 1;
|
|
510
|
+
this.activeController?.abort(new Error("Codex 历史同步配置已更改"));
|
|
511
|
+
this.wakeLatch.wake();
|
|
512
|
+
}
|
|
513
|
+
stop() {
|
|
514
|
+
if (this.stopped)
|
|
515
|
+
return;
|
|
516
|
+
this.stopped = true;
|
|
517
|
+
this.wakeGeneration += 1;
|
|
518
|
+
this.activeController?.abort(new Error("Codex Bridge 正在停止"));
|
|
519
|
+
this.wakeLatch.wake();
|
|
520
|
+
}
|
|
521
|
+
async run(signal) {
|
|
522
|
+
while (!signal.aborted && !this.stopped) {
|
|
523
|
+
const configuration = this.options.configuration();
|
|
524
|
+
if (!configuration.enabled || this.queue.length === 0) {
|
|
525
|
+
await this.wakeLatch.wait(HISTORY_DISABLED_WAIT_MS, signal);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
let processed = 0;
|
|
529
|
+
const targetBudget = Math.min(HISTORY_TARGETS_PER_SLICE, this.queue.length);
|
|
530
|
+
const sliceGeneration = this.wakeGeneration;
|
|
531
|
+
while (!signal.aborted &&
|
|
532
|
+
!this.stopped &&
|
|
533
|
+
processed < targetBudget &&
|
|
534
|
+
this.queue.length > 0 &&
|
|
535
|
+
this.options.configuration().enabled) {
|
|
536
|
+
const threadId = this.queue.shift();
|
|
537
|
+
if (!threadId)
|
|
538
|
+
break;
|
|
539
|
+
const queuedSignature = this.queuedSignatures.get(threadId) ?? null;
|
|
540
|
+
this.queuedSignatures.delete(threadId);
|
|
541
|
+
const target = this.targets.get(threadId);
|
|
542
|
+
if (!target || !queuedSignature)
|
|
543
|
+
continue;
|
|
544
|
+
const currentSignature = this.targetSignature(target, this.options.configuration().turnLimit);
|
|
545
|
+
if (currentSignature !== queuedSignature)
|
|
546
|
+
continue;
|
|
547
|
+
await this.syncTarget(target, queuedSignature, signal);
|
|
548
|
+
processed += 1;
|
|
549
|
+
}
|
|
550
|
+
const waitResult = await this.wakeLatch.wait(HISTORY_SLICE_DELAY_MS, signal);
|
|
551
|
+
if (waitResult === "wake" &&
|
|
552
|
+
!signal.aborted &&
|
|
553
|
+
this.wakeGeneration === sliceGeneration) {
|
|
554
|
+
// Consume a wake that was queued before this slice, then still honor
|
|
555
|
+
// the background budget before rescanning the same durable history.
|
|
556
|
+
await this.wakeLatch.wait(HISTORY_SLICE_DELAY_MS, signal);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
async syncTarget(target, signature, parentSignal) {
|
|
561
|
+
if (!isInteractiveHistoryThread(target.thread))
|
|
562
|
+
return;
|
|
563
|
+
const linked = linkedTimeoutController(parentSignal, HISTORY_TARGET_TIMEOUT_MS);
|
|
564
|
+
this.activeController = linked.controller;
|
|
565
|
+
this.activeThreadId = target.thread.id;
|
|
566
|
+
this.activeSignature = signature;
|
|
567
|
+
const configurationGeneration = this.configurationGeneration;
|
|
568
|
+
const turnLimit = this.options.configuration().turnLimit;
|
|
569
|
+
try {
|
|
570
|
+
await this.options.importHistory(target.sessionId, this.historyRequest([], {
|
|
571
|
+
status: "syncing",
|
|
572
|
+
turn_limit: turnLimit,
|
|
573
|
+
scanned_turns: 0,
|
|
574
|
+
total_turns: null,
|
|
575
|
+
next_cursor: null,
|
|
576
|
+
error: null,
|
|
577
|
+
}), linked.controller.signal);
|
|
578
|
+
const result = await scanThreadHistory({
|
|
579
|
+
appServer: this.options.appServer,
|
|
580
|
+
thread: target.thread,
|
|
581
|
+
turnLimit,
|
|
582
|
+
signal: linked.controller.signal,
|
|
583
|
+
});
|
|
584
|
+
if (result.items.length > HISTORY_ACTIVITIES_PER_SCAN_LIMIT) {
|
|
585
|
+
throw new Error("Codex 历史活动数超过单次同步安全上限");
|
|
586
|
+
}
|
|
587
|
+
if (!this.options.configuration().enabled)
|
|
588
|
+
return;
|
|
589
|
+
// Reaching the configured limit is a complete bounded snapshot. `partial`
|
|
590
|
+
// is reserved for a safety cap that stopped scanning before that limit.
|
|
591
|
+
const complete = !result.safetyCapReached &&
|
|
592
|
+
(result.sourceExhausted || result.scannedTurns >= turnLimit);
|
|
593
|
+
const finalStatus = complete
|
|
594
|
+
? "complete"
|
|
595
|
+
: "partial";
|
|
596
|
+
if (result.safetyCapReached) {
|
|
597
|
+
this.options.log?.(`Thread ${target.thread.id} 历史同步达到本地安全上限,已标记 partial`);
|
|
598
|
+
}
|
|
599
|
+
const finalSync = {
|
|
600
|
+
status: finalStatus,
|
|
601
|
+
turn_limit: turnLimit,
|
|
602
|
+
scanned_turns: result.scannedTurns,
|
|
603
|
+
total_turns: null,
|
|
604
|
+
next_cursor: complete ? null : (result.nextCursor ?? HISTORY_SAFETY_CAP_CURSOR),
|
|
605
|
+
error: null,
|
|
606
|
+
};
|
|
607
|
+
const importableItems = result.items.filter((item) => item.kind === "user_message" || item.kind === "assistant_message");
|
|
608
|
+
const batches = splitHistoryImportItems(this.options.runtimeInstanceId, importableItems, finalSync);
|
|
609
|
+
for (const [index, items] of batches.entries()) {
|
|
610
|
+
if (!this.options.configuration().enabled)
|
|
611
|
+
return;
|
|
612
|
+
const last = index === batches.length - 1;
|
|
613
|
+
await this.options.importHistory(target.sessionId, this.historyRequest(items, last
|
|
614
|
+
? finalSync
|
|
615
|
+
: { ...finalSync, status: "syncing", error: null }), linked.controller.signal);
|
|
616
|
+
}
|
|
617
|
+
const currentTarget = this.targets.get(target.thread.id);
|
|
618
|
+
if (currentTarget &&
|
|
619
|
+
signature ===
|
|
620
|
+
this.targetSignature(currentTarget, this.options.configuration().turnLimit)) {
|
|
621
|
+
this.completedSignatures.set(target.thread.id, signature);
|
|
622
|
+
this.failures.delete(target.thread.id);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
const currentTarget = this.targets.get(target.thread.id);
|
|
627
|
+
if (parentSignal.aborted ||
|
|
628
|
+
this.stopped ||
|
|
629
|
+
configurationGeneration !== this.configurationGeneration ||
|
|
630
|
+
!currentTarget ||
|
|
631
|
+
signature !==
|
|
632
|
+
this.targetSignature(currentTarget, this.options.configuration().turnLimit) ||
|
|
633
|
+
!this.options.configuration().enabled) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const message = sanitizeHistoryContent(abortMessage(error));
|
|
637
|
+
let errorMessage = message.slice(0, 2_000);
|
|
638
|
+
if (/^[\uD800-\uDBFF]$/.test(errorMessage.at(-1) ?? "")) {
|
|
639
|
+
errorMessage = errorMessage.slice(0, -1);
|
|
640
|
+
}
|
|
641
|
+
this.options.log?.(`Thread ${target.thread.id} 历史同步失败:${errorMessage}`);
|
|
642
|
+
const failedSync = {
|
|
643
|
+
status: "failed",
|
|
644
|
+
turn_limit: turnLimit,
|
|
645
|
+
scanned_turns: 0,
|
|
646
|
+
total_turns: null,
|
|
647
|
+
next_cursor: null,
|
|
648
|
+
error: errorMessage,
|
|
649
|
+
};
|
|
650
|
+
const previousFailure = this.failures.get(target.thread.id);
|
|
651
|
+
const attempts = previousFailure?.signature === signature
|
|
652
|
+
? previousFailure.attempts + 1
|
|
653
|
+
: 1;
|
|
654
|
+
const retryDelay = HISTORY_FAILURE_RETRY_DELAYS_MS[attempts - 1] ?? Number.POSITIVE_INFINITY;
|
|
655
|
+
this.failures.set(target.thread.id, {
|
|
656
|
+
signature,
|
|
657
|
+
attempts,
|
|
658
|
+
retryAt: Number.isFinite(retryDelay)
|
|
659
|
+
? Date.now() + retryDelay
|
|
660
|
+
: Number.POSITIVE_INFINITY,
|
|
661
|
+
});
|
|
662
|
+
await this.reportFailure(target.sessionId, failedSync, parentSignal);
|
|
663
|
+
}
|
|
664
|
+
finally {
|
|
665
|
+
if (this.activeController === linked.controller)
|
|
666
|
+
this.activeController = null;
|
|
667
|
+
if (this.activeThreadId === target.thread.id)
|
|
668
|
+
this.activeThreadId = null;
|
|
669
|
+
if (this.activeSignature === signature)
|
|
670
|
+
this.activeSignature = null;
|
|
671
|
+
linked.cleanup();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
targetSignature(target, turnLimit) {
|
|
675
|
+
const updatedAt = target.thread.updatedAt;
|
|
676
|
+
const updatedMarker = typeof updatedAt === "number" && Number.isFinite(updatedAt)
|
|
677
|
+
? String(updatedAt)
|
|
678
|
+
: `legacy:${String(target.thread.createdAt ?? "unknown")}`;
|
|
679
|
+
return JSON.stringify([
|
|
680
|
+
target.sessionId,
|
|
681
|
+
updatedMarker,
|
|
682
|
+
turnLimit,
|
|
683
|
+
]);
|
|
684
|
+
}
|
|
685
|
+
async reportFailure(sessionId, sync, parentSignal) {
|
|
686
|
+
const bounded = linkedTimeoutController(parentSignal, HISTORY_FAILURE_REPORT_TIMEOUT_MS);
|
|
687
|
+
// Keep configuration changes, target removal, and stop able to cancel this
|
|
688
|
+
// independent best-effort status request as well as its short timeout.
|
|
689
|
+
this.activeController = bounded.controller;
|
|
690
|
+
const operation = this.options
|
|
691
|
+
.importHistory(sessionId, this.historyRequest([], sync), bounded.controller.signal)
|
|
692
|
+
.then(() => undefined)
|
|
693
|
+
.catch(() => undefined);
|
|
694
|
+
try {
|
|
695
|
+
await Promise.race([operation, waitUntilAborted(bounded.controller.signal)]);
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
bounded.controller.abort(new Error("Codex 历史状态回报已结束"));
|
|
699
|
+
if (this.activeController === bounded.controller) {
|
|
700
|
+
this.activeController = null;
|
|
701
|
+
}
|
|
702
|
+
bounded.cleanup();
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
historyRequest(items, sync) {
|
|
706
|
+
if (this.reportSequence >= Number.MAX_SAFE_INTEGER) {
|
|
707
|
+
throw new Error("Codex 历史状态序列已耗尽,请重启 Bridge");
|
|
708
|
+
}
|
|
709
|
+
this.reportSequence += 1;
|
|
710
|
+
return {
|
|
711
|
+
runtime_instance_id: this.options.runtimeInstanceId,
|
|
712
|
+
report_sequence: this.reportSequence,
|
|
713
|
+
items,
|
|
714
|
+
sync,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
//# sourceMappingURL=history-sync.js.map
|