@cueai/omni-reader-mcp 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -26
- package/dist/artifact-store.d.ts +11 -0
- package/dist/artifact-store.js +94 -48
- package/dist/cli/agent-config.d.ts +29 -4
- package/dist/cli/agent-config.js +910 -107
- package/dist/cli/arguments.d.ts +32 -0
- package/dist/cli/arguments.js +120 -0
- package/dist/cli/doctor.d.ts +42 -1
- package/dist/cli/doctor.js +109 -36
- package/dist/cli/setup.d.ts +3 -0
- package/dist/cli/setup.js +103 -18
- package/dist/cli/uninstall.d.ts +6 -0
- package/dist/cli/uninstall.js +37 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +7 -0
- package/dist/cube-client.d.ts +5 -3
- package/dist/cube-client.js +16 -11
- package/dist/cursor.js +2 -0
- package/dist/errors.d.ts +32 -1
- package/dist/errors.js +26 -1
- package/dist/iiis-client.d.ts +18 -4
- package/dist/iiis-client.js +194 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +93 -32
- package/dist/multipart-body.js +2 -0
- package/dist/onboarding-policy.d.ts +10 -0
- package/dist/onboarding-policy.js +58 -0
- package/dist/operation-journal.d.ts +50 -1
- package/dist/operation-journal.js +473 -114
- package/dist/operation-manager.d.ts +75 -0
- package/dist/operation-manager.js +1311 -0
- package/dist/path-security.d.ts +1 -0
- package/dist/path-security.js +26 -6
- package/dist/progress.d.ts +6 -1
- package/dist/protocol.d.ts +26 -13
- package/dist/protocol.js +34 -10
- package/dist/remote-client.d.ts +17 -0
- package/dist/remote-client.js +233 -0
- package/dist/result-contract.d.ts +199 -0
- package/dist/result-contract.js +235 -0
- package/dist/server.js +21 -4
- package/dist/source.d.ts +8 -0
- package/dist/source.js +37 -0
- package/dist/task-runtime.d.ts +13 -0
- package/dist/task-runtime.js +94 -0
- package/dist/tools.d.ts +19 -1
- package/dist/tools.js +317 -112
- package/package.json +3 -3
|
@@ -1,13 +1,55 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, } from "node:crypto";
|
|
2
|
-
import { chmod, link, mkdir, open, readFile, unlink, } from "node:fs/promises";
|
|
2
|
+
import { chmod, link, mkdir, open, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { OmniBridgeError } from "./errors.js";
|
|
6
|
+
const JOURNAL_STATES = new Set([
|
|
7
|
+
"CREATED",
|
|
8
|
+
"GRANT_PENDING",
|
|
9
|
+
"GRANT_ISSUED",
|
|
10
|
+
"UPLOADING",
|
|
11
|
+
"PROCESSING",
|
|
12
|
+
"RESULT_READY",
|
|
13
|
+
"ACK_PENDING",
|
|
14
|
+
"CLEANUP_PENDING",
|
|
15
|
+
"COMPLETED",
|
|
16
|
+
"FAILED",
|
|
17
|
+
"CANCELED",
|
|
18
|
+
"EXPIRED",
|
|
19
|
+
]);
|
|
20
|
+
const TERMINAL_STATES = new Set([
|
|
21
|
+
"COMPLETED",
|
|
22
|
+
"FAILED",
|
|
23
|
+
"CANCELED",
|
|
24
|
+
"EXPIRED",
|
|
25
|
+
]);
|
|
26
|
+
const PROGRESS_UNITS = new Set([
|
|
27
|
+
"page",
|
|
28
|
+
"sheet",
|
|
29
|
+
"slide",
|
|
30
|
+
"frame",
|
|
31
|
+
"segment",
|
|
32
|
+
]);
|
|
33
|
+
const CLEANUP_STATES = new Set([
|
|
34
|
+
"not_created",
|
|
35
|
+
"in_use",
|
|
36
|
+
"pending",
|
|
37
|
+
"deleted",
|
|
38
|
+
]);
|
|
39
|
+
const DELIVERY_STATES = new Set([
|
|
40
|
+
"not_created",
|
|
41
|
+
"pending",
|
|
42
|
+
"deleted_after_ack",
|
|
43
|
+
]);
|
|
44
|
+
const RECORD_FILE_PATTERN = /^[0-9a-f]{64}(?:\.issued)?\.json$/u;
|
|
45
|
+
const LOCK_STALE_MS = 30_000;
|
|
6
46
|
function bridgeError(code, message, retryable) {
|
|
7
47
|
return new OmniBridgeError({
|
|
8
48
|
code,
|
|
9
49
|
message,
|
|
50
|
+
operationCreated: false,
|
|
10
51
|
fileUploaded: false,
|
|
52
|
+
parserStarted: false,
|
|
11
53
|
billed: false,
|
|
12
54
|
contentReleased: false,
|
|
13
55
|
retryable,
|
|
@@ -28,40 +70,166 @@ function recordFileName(clientRequestId) {
|
|
|
28
70
|
}
|
|
29
71
|
function validateClientRequestId(clientRequestId) {
|
|
30
72
|
if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(clientRequestId)) {
|
|
31
|
-
throw bridgeError("INVALID_CLIENT_REQUEST_ID", "The local
|
|
73
|
+
throw bridgeError("INVALID_CLIENT_REQUEST_ID", "The local operation request identifier is invalid.", false);
|
|
32
74
|
}
|
|
33
75
|
}
|
|
76
|
+
function isStringOrNull(value) {
|
|
77
|
+
return value === null || typeof value === "string";
|
|
78
|
+
}
|
|
34
79
|
function isEncryptedToken(value) {
|
|
35
|
-
if (value === null || typeof value !== "object")
|
|
80
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
36
81
|
return false;
|
|
37
|
-
}
|
|
38
82
|
const token = value;
|
|
39
83
|
return (token.algorithm === "aes-256-gcm" &&
|
|
40
84
|
typeof token.nonce === "string" &&
|
|
41
85
|
typeof token.ciphertext === "string" &&
|
|
42
86
|
typeof token.tag === "string");
|
|
43
87
|
}
|
|
88
|
+
function isProgress(value) {
|
|
89
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
90
|
+
return false;
|
|
91
|
+
const progress = value;
|
|
92
|
+
return (typeof progress.unit === "string" &&
|
|
93
|
+
PROGRESS_UNITS.has(progress.unit) &&
|
|
94
|
+
Number.isSafeInteger(progress.completed) &&
|
|
95
|
+
Number.isSafeInteger(progress.total) &&
|
|
96
|
+
(progress.completed ?? -1) >= 0 &&
|
|
97
|
+
(progress.total ?? 0) > 0 &&
|
|
98
|
+
(progress.completed ?? 1) <= (progress.total ?? 0));
|
|
99
|
+
}
|
|
100
|
+
function parseLegacyRecord(value) {
|
|
101
|
+
if (value.version !== 1 ||
|
|
102
|
+
typeof value.clientRequestId !== "string" ||
|
|
103
|
+
typeof value.requestHash !== "string" ||
|
|
104
|
+
!isStringOrNull(value.operationId) ||
|
|
105
|
+
!(value.operationToken === null || isEncryptedToken(value.operationToken)) ||
|
|
106
|
+
!isStringOrNull(value.uploadUrl) ||
|
|
107
|
+
(value.state !== "GRANT_PENDING" && value.state !== "GRANT_ISSUED") ||
|
|
108
|
+
typeof value.createdAt !== "string" ||
|
|
109
|
+
!isStringOrNull(value.expiresAt)) {
|
|
110
|
+
throw new Error("legacy record fields are invalid");
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function parseVersionTwoRecord(value) {
|
|
115
|
+
if (value.version !== 2 ||
|
|
116
|
+
typeof value.clientRequestId !== "string" ||
|
|
117
|
+
typeof value.requestHash !== "string" ||
|
|
118
|
+
!(value.sourceLocatorHash === undefined ||
|
|
119
|
+
isStringOrNull(value.sourceLocatorHash)) ||
|
|
120
|
+
!(value.sourceKind === undefined ||
|
|
121
|
+
value.sourceKind === "local" ||
|
|
122
|
+
value.sourceKind === "url") ||
|
|
123
|
+
!isStringOrNull(value.operationId) ||
|
|
124
|
+
!(value.operationToken === null || isEncryptedToken(value.operationToken)) ||
|
|
125
|
+
typeof value.state !== "string" ||
|
|
126
|
+
!JOURNAL_STATES.has(value.state) ||
|
|
127
|
+
typeof value.createdAt !== "string" ||
|
|
128
|
+
typeof value.updatedAt !== "string" ||
|
|
129
|
+
!isStringOrNull(value.expiresAt) ||
|
|
130
|
+
!isStringOrNull(value.stage) ||
|
|
131
|
+
!(value.progressPercent === undefined ||
|
|
132
|
+
(typeof value.progressPercent === "number" &&
|
|
133
|
+
Number.isFinite(value.progressPercent) &&
|
|
134
|
+
value.progressPercent >= 0 &&
|
|
135
|
+
value.progressPercent <= 100)) ||
|
|
136
|
+
!(value.progress === null || isProgress(value.progress)) ||
|
|
137
|
+
typeof value.fileUploaded !== "boolean" ||
|
|
138
|
+
typeof value.parserStarted !== "boolean" ||
|
|
139
|
+
typeof value.billed !== "boolean" ||
|
|
140
|
+
typeof value.contentReleased !== "boolean" ||
|
|
141
|
+
typeof value.processingCopy !== "string" ||
|
|
142
|
+
!CLEANUP_STATES.has(value.processingCopy) ||
|
|
143
|
+
typeof value.temporaryData !== "string" ||
|
|
144
|
+
!CLEANUP_STATES.has(value.temporaryData) ||
|
|
145
|
+
typeof value.deliveryResult !== "string" ||
|
|
146
|
+
!DELIVERY_STATES.has(value.deliveryResult) ||
|
|
147
|
+
!isStringOrNull(value.resultId) ||
|
|
148
|
+
!isStringOrNull(value.resultExpiresAt) ||
|
|
149
|
+
!isStringOrNull(value.errorCode)) {
|
|
150
|
+
throw new Error("version-2 record fields are invalid");
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
...value,
|
|
154
|
+
sourceLocatorHash: value.sourceLocatorHash ?? null,
|
|
155
|
+
sourceKind: value.sourceKind ?? "local",
|
|
156
|
+
progressPercent: value.progressPercent ?? 0,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
44
159
|
function parsePersistedRecord(value) {
|
|
45
|
-
if (value === null || typeof value !== "object") {
|
|
160
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
46
161
|
throw new Error("record is not an object");
|
|
47
162
|
}
|
|
48
163
|
const record = value;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
164
|
+
return record.version === 1 ? parseLegacyRecord(record) : parseVersionTwoRecord(record);
|
|
165
|
+
}
|
|
166
|
+
function migrateRecord(record) {
|
|
167
|
+
if (record.version === 2)
|
|
168
|
+
return record;
|
|
169
|
+
return {
|
|
170
|
+
version: 2,
|
|
171
|
+
clientRequestId: record.clientRequestId,
|
|
172
|
+
requestHash: record.requestHash,
|
|
173
|
+
sourceLocatorHash: null,
|
|
174
|
+
sourceKind: "local",
|
|
175
|
+
operationId: record.operationId,
|
|
176
|
+
operationToken: record.operationToken,
|
|
177
|
+
state: record.state,
|
|
178
|
+
createdAt: record.createdAt,
|
|
179
|
+
updatedAt: record.createdAt,
|
|
180
|
+
expiresAt: record.expiresAt,
|
|
181
|
+
stage: null,
|
|
182
|
+
progressPercent: 0,
|
|
183
|
+
progress: null,
|
|
184
|
+
fileUploaded: false,
|
|
185
|
+
parserStarted: false,
|
|
186
|
+
billed: false,
|
|
187
|
+
contentReleased: false,
|
|
188
|
+
processingCopy: "not_created",
|
|
189
|
+
temporaryData: "not_created",
|
|
190
|
+
deliveryResult: "not_created",
|
|
191
|
+
resultId: null,
|
|
192
|
+
resultExpiresAt: null,
|
|
193
|
+
errorCode: null,
|
|
194
|
+
};
|
|
61
195
|
}
|
|
62
196
|
async function closeQuietly(handle) {
|
|
63
197
|
await handle?.close().catch(() => undefined);
|
|
64
198
|
}
|
|
199
|
+
function sleep(milliseconds) {
|
|
200
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
201
|
+
}
|
|
202
|
+
function assertStableTransition(current, updated) {
|
|
203
|
+
for (const [before, after] of [
|
|
204
|
+
[current.operationId, updated.operationId],
|
|
205
|
+
[current.resultId, updated.resultId],
|
|
206
|
+
]) {
|
|
207
|
+
if (before !== null && before !== after) {
|
|
208
|
+
throw bridgeError("JOURNAL_IDENTITY_CONFLICT", "A stable local operation identifier cannot be replaced.", false);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (current.operationToken !== null &&
|
|
212
|
+
current.operationToken !== updated.operationToken &&
|
|
213
|
+
!(updated.operationToken === null && TERMINAL_STATES.has(updated.state))) {
|
|
214
|
+
throw bridgeError("JOURNAL_IDENTITY_CONFLICT", "A recoverable local operation token cannot be replaced.", false);
|
|
215
|
+
}
|
|
216
|
+
if (updated.progressPercent < current.progressPercent ||
|
|
217
|
+
(current.progress !== null && (updated.progress === null ||
|
|
218
|
+
updated.progress.unit !== current.progress.unit ||
|
|
219
|
+
updated.progress.completed < current.progress.completed))) {
|
|
220
|
+
throw bridgeError("JOURNAL_PROGRESS_REGRESSION", "Confirmed local operation progress cannot move backward.", false);
|
|
221
|
+
}
|
|
222
|
+
for (const [before, after] of [
|
|
223
|
+
[current.fileUploaded, updated.fileUploaded],
|
|
224
|
+
[current.parserStarted, updated.parserStarted],
|
|
225
|
+
[current.billed, updated.billed],
|
|
226
|
+
[current.contentReleased, updated.contentReleased],
|
|
227
|
+
]) {
|
|
228
|
+
if (before && !after) {
|
|
229
|
+
throw bridgeError("JOURNAL_FACT_REGRESSION", "A confirmed local operation fact cannot be reverted.", false);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
65
233
|
export class OperationJournal {
|
|
66
234
|
#rootDirectory;
|
|
67
235
|
#now;
|
|
@@ -70,105 +238,262 @@ export class OperationJournal {
|
|
|
70
238
|
this.#rootDirectory = options.rootDirectory ?? defaultRootDirectory();
|
|
71
239
|
this.#now = options.now ?? (() => new Date());
|
|
72
240
|
}
|
|
73
|
-
async
|
|
241
|
+
async beginIntent(clientRequestId, requestHash, sourceKind = "local", sourceLocatorHash = null) {
|
|
74
242
|
validateClientRequestId(clientRequestId);
|
|
75
243
|
const existing = await this.#readRecord(clientRequestId);
|
|
76
244
|
if (existing !== null) {
|
|
77
245
|
return this.#requireMatchingRequest(clientRequestId, requestHash, existing);
|
|
78
246
|
}
|
|
79
|
-
const
|
|
80
|
-
|
|
247
|
+
const createdAt = this.#now().toISOString();
|
|
248
|
+
const created = {
|
|
249
|
+
version: 2,
|
|
81
250
|
clientRequestId,
|
|
82
251
|
requestHash,
|
|
252
|
+
sourceLocatorHash,
|
|
253
|
+
sourceKind,
|
|
83
254
|
operationId: null,
|
|
84
255
|
operationToken: null,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
256
|
+
state: "CREATED",
|
|
257
|
+
createdAt,
|
|
258
|
+
updatedAt: createdAt,
|
|
88
259
|
expiresAt: null,
|
|
260
|
+
stage: null,
|
|
261
|
+
progressPercent: 0,
|
|
262
|
+
progress: null,
|
|
263
|
+
fileUploaded: false,
|
|
264
|
+
parserStarted: false,
|
|
265
|
+
billed: false,
|
|
266
|
+
contentReleased: false,
|
|
267
|
+
processingCopy: "not_created",
|
|
268
|
+
temporaryData: "not_created",
|
|
269
|
+
deliveryResult: "not_created",
|
|
270
|
+
resultId: null,
|
|
271
|
+
resultExpiresAt: null,
|
|
272
|
+
errorCode: null,
|
|
89
273
|
};
|
|
90
|
-
if (await this.#createRecord(
|
|
91
|
-
return this.#
|
|
274
|
+
if (await this.#createRecord(created, this.#recordPath(clientRequestId))) {
|
|
275
|
+
return this.#recordToPublic(clientRequestId, created);
|
|
92
276
|
}
|
|
93
277
|
const raced = await this.#readRecord(clientRequestId);
|
|
94
278
|
if (raced === null) {
|
|
95
|
-
throw bridgeError("JOURNAL_WRITE_FAILED", "The local
|
|
279
|
+
throw bridgeError("JOURNAL_WRITE_FAILED", "The local operation journal could not be saved.", true);
|
|
96
280
|
}
|
|
97
281
|
return this.#requireMatchingRequest(clientRequestId, requestHash, raced);
|
|
98
282
|
}
|
|
99
|
-
async
|
|
283
|
+
async transition(clientRequestId, expectedState, nextState, patch) {
|
|
284
|
+
validateClientRequestId(clientRequestId);
|
|
285
|
+
if (!JOURNAL_STATES.has(expectedState) || !JOURNAL_STATES.has(nextState)) {
|
|
286
|
+
throw bridgeError("INVALID_JOURNAL_STATE", "The local operation state is invalid.", false);
|
|
287
|
+
}
|
|
288
|
+
return this.#withRecordLock(clientRequestId, async () => {
|
|
289
|
+
const stored = await this.#readRecord(clientRequestId);
|
|
290
|
+
if (stored === null) {
|
|
291
|
+
throw bridgeError("JOURNAL_RECORD_NOT_FOUND", "The local operation journal record is missing.", true);
|
|
292
|
+
}
|
|
293
|
+
const current = await this.#recordToPublic(clientRequestId, stored);
|
|
294
|
+
if (current.state !== expectedState) {
|
|
295
|
+
throw bridgeError("JOURNAL_STATE_CONFLICT", "The local operation changed before this transition was saved.", true);
|
|
296
|
+
}
|
|
297
|
+
const updated = {
|
|
298
|
+
...current,
|
|
299
|
+
...patch,
|
|
300
|
+
...(TERMINAL_STATES.has(nextState) ? { operationToken: null } : {}),
|
|
301
|
+
uploadUrl: null,
|
|
302
|
+
state: nextState,
|
|
303
|
+
updatedAt: this.#now().toISOString(),
|
|
304
|
+
};
|
|
305
|
+
assertStableTransition(current, updated);
|
|
306
|
+
await this.#replaceRecord(await this.#persistedRecord(updated), this.#recordPath(clientRequestId));
|
|
307
|
+
await unlink(this.#legacyIssuedRecordPath(clientRequestId)).catch(() => undefined);
|
|
308
|
+
return updated;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
async loadByRequestId(clientRequestId) {
|
|
312
|
+
validateClientRequestId(clientRequestId);
|
|
100
313
|
const persisted = await this.#readRecord(clientRequestId);
|
|
101
|
-
|
|
102
|
-
|
|
314
|
+
return persisted === null ? null : this.#recordToPublic(clientRequestId, persisted);
|
|
315
|
+
}
|
|
316
|
+
async loadByOperationId(operationId) {
|
|
317
|
+
if (operationId.length === 0)
|
|
318
|
+
return null;
|
|
319
|
+
return (await this.#listRecords()).find((record) => record.operationId === operationId) ?? null;
|
|
320
|
+
}
|
|
321
|
+
async loadLatestByRequestHash(requestHash) {
|
|
322
|
+
return (await this.#listRecords())
|
|
323
|
+
.filter((record) => record.requestHash === requestHash)
|
|
324
|
+
.at(-1) ?? null;
|
|
325
|
+
}
|
|
326
|
+
async loadLatestBySourceLocatorHash(sourceLocatorHash) {
|
|
327
|
+
return (await this.#listRecords())
|
|
328
|
+
.filter((record) => record.sourceLocatorHash === sourceLocatorHash)
|
|
329
|
+
.at(-1) ?? null;
|
|
330
|
+
}
|
|
331
|
+
async listRecoverable() {
|
|
332
|
+
return (await this.#listRecords()).filter((record) => !TERMINAL_STATES.has(record.state));
|
|
333
|
+
}
|
|
334
|
+
async begin(clientRequestId, requestHash) {
|
|
335
|
+
const intent = await this.beginIntent(clientRequestId, requestHash);
|
|
336
|
+
if (intent.state !== "CREATED")
|
|
337
|
+
return intent;
|
|
338
|
+
try {
|
|
339
|
+
return await this.transition(clientRequestId, "CREATED", "GRANT_PENDING", {});
|
|
103
340
|
}
|
|
104
|
-
|
|
105
|
-
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (!(error instanceof OmniBridgeError) || error.code !== "JOURNAL_STATE_CONFLICT")
|
|
343
|
+
throw error;
|
|
344
|
+
const raced = await this.loadByRequestId(clientRequestId);
|
|
345
|
+
if (raced === null || raced.requestHash !== requestHash)
|
|
346
|
+
throw error;
|
|
347
|
+
return raced;
|
|
106
348
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
uploadUrl: fields.uploadUrl,
|
|
113
|
-
state: "GRANT_ISSUED",
|
|
114
|
-
expiresAt: fields.expiresAt,
|
|
115
|
-
};
|
|
116
|
-
if (await this.#createRecord(issued, this.#issuedRecordPath(clientRequestId))) {
|
|
117
|
-
return this.#publicRecord(issued, fields.operationToken);
|
|
349
|
+
}
|
|
350
|
+
async markGrantIssued(clientRequestId, fields) {
|
|
351
|
+
const current = await this.loadByRequestId(clientRequestId);
|
|
352
|
+
if (current === null) {
|
|
353
|
+
throw bridgeError("JOURNAL_RECORD_NOT_FOUND", "The local operation journal record is missing.", true);
|
|
118
354
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
355
|
+
if (current.state !== "GRANT_PENDING")
|
|
356
|
+
return this.#requireMatchingGrant(current, fields);
|
|
357
|
+
try {
|
|
358
|
+
return await this.transition(clientRequestId, "GRANT_PENDING", "GRANT_ISSUED", {
|
|
359
|
+
operationId: fields.operationId,
|
|
360
|
+
operationToken: fields.operationToken,
|
|
361
|
+
expiresAt: fields.expiresAt,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
if (!(error instanceof OmniBridgeError) || error.code !== "JOURNAL_STATE_CONFLICT")
|
|
366
|
+
throw error;
|
|
367
|
+
const raced = await this.loadByRequestId(clientRequestId);
|
|
368
|
+
if (raced === null)
|
|
369
|
+
throw error;
|
|
370
|
+
return this.#requireMatchingGrant(raced, fields);
|
|
122
371
|
}
|
|
123
|
-
return this.#requireMatchingGrant(clientRequestId, raced, fields);
|
|
124
372
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
373
|
+
load(clientRequestId) {
|
|
374
|
+
return this.loadByRequestId(clientRequestId);
|
|
375
|
+
}
|
|
376
|
+
async #listRecords() {
|
|
377
|
+
let names;
|
|
378
|
+
try {
|
|
379
|
+
names = await readdir(this.#rootDirectory);
|
|
129
380
|
}
|
|
130
|
-
|
|
381
|
+
catch (error) {
|
|
382
|
+
if (error.code === "ENOENT")
|
|
383
|
+
return [];
|
|
384
|
+
throw bridgeError("JOURNAL_READ_FAILED", "The local operation journal could not be read.", true);
|
|
385
|
+
}
|
|
386
|
+
const requestIds = new Set();
|
|
387
|
+
for (const name of names.filter((entry) => RECORD_FILE_PATTERN.test(entry))) {
|
|
388
|
+
const stored = await this.#readRecordFile(undefined, path.join(this.#rootDirectory, name));
|
|
389
|
+
if (stored !== null)
|
|
390
|
+
requestIds.add(stored.clientRequestId);
|
|
391
|
+
}
|
|
392
|
+
const records = [];
|
|
393
|
+
for (const requestId of requestIds) {
|
|
394
|
+
const record = await this.loadByRequestId(requestId);
|
|
395
|
+
if (record !== null)
|
|
396
|
+
records.push(record);
|
|
397
|
+
}
|
|
398
|
+
return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt) ||
|
|
399
|
+
left.clientRequestId.localeCompare(right.clientRequestId));
|
|
131
400
|
}
|
|
132
|
-
async #
|
|
401
|
+
async #persistedRecord(record) {
|
|
402
|
+
return {
|
|
403
|
+
version: 2,
|
|
404
|
+
clientRequestId: record.clientRequestId,
|
|
405
|
+
requestHash: record.requestHash,
|
|
406
|
+
sourceLocatorHash: record.sourceLocatorHash,
|
|
407
|
+
sourceKind: record.sourceKind,
|
|
408
|
+
operationId: record.operationId,
|
|
409
|
+
operationToken: record.operationToken === null
|
|
410
|
+
? null
|
|
411
|
+
: await this.#encryptToken(record.clientRequestId, record.operationToken),
|
|
412
|
+
state: record.state,
|
|
413
|
+
createdAt: record.createdAt,
|
|
414
|
+
updatedAt: record.updatedAt,
|
|
415
|
+
expiresAt: record.expiresAt,
|
|
416
|
+
stage: record.stage,
|
|
417
|
+
progressPercent: record.progressPercent,
|
|
418
|
+
progress: record.progress,
|
|
419
|
+
fileUploaded: record.fileUploaded,
|
|
420
|
+
parserStarted: record.parserStarted,
|
|
421
|
+
billed: record.billed,
|
|
422
|
+
contentReleased: record.contentReleased,
|
|
423
|
+
processingCopy: record.processingCopy,
|
|
424
|
+
temporaryData: record.temporaryData,
|
|
425
|
+
deliveryResult: record.deliveryResult,
|
|
426
|
+
resultId: record.resultId,
|
|
427
|
+
resultExpiresAt: record.resultExpiresAt,
|
|
428
|
+
errorCode: record.errorCode,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async #recordToPublic(clientRequestId, stored) {
|
|
432
|
+
const persisted = migrateRecord(stored);
|
|
133
433
|
const operationToken = persisted.operationToken === null
|
|
134
434
|
? null
|
|
135
435
|
: await this.#decryptToken(clientRequestId, persisted.operationToken);
|
|
136
|
-
return
|
|
436
|
+
return {
|
|
437
|
+
clientRequestId: persisted.clientRequestId,
|
|
438
|
+
requestHash: persisted.requestHash,
|
|
439
|
+
sourceLocatorHash: persisted.sourceLocatorHash,
|
|
440
|
+
sourceKind: persisted.sourceKind,
|
|
441
|
+
operationId: persisted.operationId,
|
|
442
|
+
operationToken,
|
|
443
|
+
uploadUrl: stored.version === 1 ? stored.uploadUrl : null,
|
|
444
|
+
state: persisted.state,
|
|
445
|
+
createdAt: persisted.createdAt,
|
|
446
|
+
updatedAt: persisted.updatedAt,
|
|
447
|
+
expiresAt: persisted.expiresAt,
|
|
448
|
+
stage: persisted.stage,
|
|
449
|
+
progressPercent: persisted.progressPercent,
|
|
450
|
+
progress: persisted.progress,
|
|
451
|
+
fileUploaded: persisted.fileUploaded,
|
|
452
|
+
parserStarted: persisted.parserStarted,
|
|
453
|
+
billed: persisted.billed,
|
|
454
|
+
contentReleased: persisted.contentReleased,
|
|
455
|
+
processingCopy: persisted.processingCopy,
|
|
456
|
+
temporaryData: persisted.temporaryData,
|
|
457
|
+
deliveryResult: persisted.deliveryResult,
|
|
458
|
+
resultId: persisted.resultId,
|
|
459
|
+
resultExpiresAt: persisted.resultExpiresAt,
|
|
460
|
+
errorCode: persisted.errorCode,
|
|
461
|
+
};
|
|
137
462
|
}
|
|
138
463
|
async #requireMatchingRequest(clientRequestId, requestHash, persisted) {
|
|
139
464
|
if (persisted.requestHash !== requestHash) {
|
|
140
|
-
throw bridgeError("IDEMPOTENCY_COLLISION", "This local
|
|
465
|
+
throw bridgeError("IDEMPOTENCY_COLLISION", "This local operation request identifier is already bound to different metadata.", false);
|
|
141
466
|
}
|
|
142
467
|
return this.#recordToPublic(clientRequestId, persisted);
|
|
143
468
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
existing.uploadUrl !== fields.uploadUrl ||
|
|
149
|
-
existing.expiresAt !== fields.expiresAt) {
|
|
469
|
+
#requireMatchingGrant(current, fields) {
|
|
470
|
+
if (current.operationId !== fields.operationId ||
|
|
471
|
+
current.operationToken !== fields.operationToken ||
|
|
472
|
+
current.expiresAt !== fields.expiresAt) {
|
|
150
473
|
throw bridgeError("JOURNAL_GRANT_CONFLICT", "Cube returned conflicting data for the same grant request.", false);
|
|
151
474
|
}
|
|
152
|
-
return
|
|
475
|
+
return current;
|
|
153
476
|
}
|
|
154
477
|
async #ensureRoot() {
|
|
155
478
|
await mkdir(this.#rootDirectory, { recursive: true, mode: 0o700 });
|
|
156
|
-
if (process.platform !== "win32")
|
|
479
|
+
if (process.platform !== "win32")
|
|
157
480
|
await chmod(this.#rootDirectory, 0o700);
|
|
158
|
-
}
|
|
159
481
|
}
|
|
160
|
-
#
|
|
482
|
+
#recordPath(clientRequestId) {
|
|
161
483
|
return path.join(this.#rootDirectory, recordFileName(clientRequestId));
|
|
162
484
|
}
|
|
163
|
-
#
|
|
164
|
-
return this.#
|
|
485
|
+
#legacyIssuedRecordPath(clientRequestId) {
|
|
486
|
+
return this.#recordPath(clientRequestId).replace(/\.json$/u, ".issued.json");
|
|
487
|
+
}
|
|
488
|
+
#lockPath(clientRequestId) {
|
|
489
|
+
return this.#recordPath(clientRequestId).replace(/\.json$/u, ".lock");
|
|
165
490
|
}
|
|
166
491
|
async #readRecord(clientRequestId) {
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
169
|
-
return
|
|
170
|
-
|
|
171
|
-
return
|
|
492
|
+
const current = await this.#readRecordFile(clientRequestId, this.#recordPath(clientRequestId));
|
|
493
|
+
if (current?.version === 2)
|
|
494
|
+
return current;
|
|
495
|
+
const legacyIssued = await this.#readRecordFile(clientRequestId, this.#legacyIssuedRecordPath(clientRequestId));
|
|
496
|
+
return legacyIssued ?? current;
|
|
172
497
|
}
|
|
173
498
|
async #readRecordFile(clientRequestId, recordPath) {
|
|
174
499
|
let serialized;
|
|
@@ -176,20 +501,20 @@ export class OperationJournal {
|
|
|
176
501
|
serialized = await readFile(recordPath, "utf8");
|
|
177
502
|
}
|
|
178
503
|
catch (error) {
|
|
179
|
-
if (error.code === "ENOENT")
|
|
504
|
+
if (error.code === "ENOENT")
|
|
180
505
|
return null;
|
|
181
|
-
|
|
182
|
-
throw bridgeError("JOURNAL_READ_FAILED", "The local grant journal could not be read.", true);
|
|
506
|
+
throw bridgeError("JOURNAL_READ_FAILED", "The local operation journal could not be read.", true);
|
|
183
507
|
}
|
|
184
508
|
try {
|
|
185
509
|
const persisted = parsePersistedRecord(JSON.parse(serialized));
|
|
186
|
-
if (persisted.clientRequestId !== clientRequestId) {
|
|
510
|
+
if (clientRequestId !== undefined && persisted.clientRequestId !== clientRequestId) {
|
|
187
511
|
throw new Error("record identity mismatch");
|
|
188
512
|
}
|
|
513
|
+
validateClientRequestId(persisted.clientRequestId);
|
|
189
514
|
return persisted;
|
|
190
515
|
}
|
|
191
516
|
catch {
|
|
192
|
-
throw bridgeError("JOURNAL_CORRUPT", "The local
|
|
517
|
+
throw bridgeError("JOURNAL_CORRUPT", "The local operation journal is invalid.", false);
|
|
193
518
|
}
|
|
194
519
|
}
|
|
195
520
|
async #createRecord(record, recordPath) {
|
|
@@ -203,42 +528,96 @@ export class OperationJournal {
|
|
|
203
528
|
await handle.close();
|
|
204
529
|
handle = undefined;
|
|
205
530
|
await link(temporaryPath, recordPath);
|
|
206
|
-
if (process.platform !== "win32")
|
|
531
|
+
if (process.platform !== "win32")
|
|
207
532
|
await chmod(recordPath, 0o600);
|
|
208
|
-
}
|
|
209
533
|
await this.#syncDirectory();
|
|
210
534
|
return true;
|
|
211
535
|
}
|
|
212
536
|
catch (error) {
|
|
213
537
|
await closeQuietly(handle);
|
|
214
|
-
if (error.code === "EEXIST")
|
|
538
|
+
if (error.code === "EEXIST")
|
|
215
539
|
return false;
|
|
216
|
-
|
|
217
|
-
|
|
540
|
+
throw bridgeError("JOURNAL_WRITE_FAILED", "The local operation journal could not be saved.", true);
|
|
541
|
+
}
|
|
542
|
+
finally {
|
|
543
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
async #replaceRecord(record, recordPath) {
|
|
547
|
+
await this.#ensureRoot();
|
|
548
|
+
const temporaryPath = path.join(this.#rootDirectory, `.${path.basename(recordPath)}.${randomUUID()}.tmp`);
|
|
549
|
+
let handle;
|
|
550
|
+
try {
|
|
551
|
+
handle = await open(temporaryPath, "wx", 0o600);
|
|
552
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
553
|
+
await handle.sync();
|
|
554
|
+
await handle.close();
|
|
555
|
+
handle = undefined;
|
|
556
|
+
await rename(temporaryPath, recordPath);
|
|
557
|
+
if (process.platform !== "win32")
|
|
558
|
+
await chmod(recordPath, 0o600);
|
|
559
|
+
await this.#syncDirectory();
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
await closeQuietly(handle);
|
|
563
|
+
throw bridgeError("JOURNAL_WRITE_FAILED", "The local operation journal could not be saved.", true);
|
|
218
564
|
}
|
|
219
565
|
finally {
|
|
220
566
|
await unlink(temporaryPath).catch(() => undefined);
|
|
221
567
|
}
|
|
222
568
|
}
|
|
569
|
+
async #withRecordLock(clientRequestId, action) {
|
|
570
|
+
await this.#ensureRoot();
|
|
571
|
+
const lockPath = this.#lockPath(clientRequestId);
|
|
572
|
+
for (let attempt = 0; attempt < 500; attempt += 1) {
|
|
573
|
+
try {
|
|
574
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
575
|
+
try {
|
|
576
|
+
return await action();
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
if (error.code !== "EEXIST")
|
|
584
|
+
throw error;
|
|
585
|
+
if (await this.#removeStaleLock(lockPath))
|
|
586
|
+
continue;
|
|
587
|
+
await sleep(10);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
throw bridgeError("JOURNAL_LOCK_TIMEOUT", "The local operation journal remained busy.", true);
|
|
591
|
+
}
|
|
592
|
+
async #removeStaleLock(lockPath) {
|
|
593
|
+
try {
|
|
594
|
+
const lock = await stat(lockPath);
|
|
595
|
+
if (Date.now() - lock.mtimeMs <= LOCK_STALE_MS)
|
|
596
|
+
return false;
|
|
597
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
return error.code === "ENOENT";
|
|
602
|
+
}
|
|
603
|
+
}
|
|
223
604
|
async #journalKey() {
|
|
224
605
|
this.#keyPromise ??= this.#loadOrCreateKey();
|
|
225
606
|
return this.#keyPromise;
|
|
226
607
|
}
|
|
227
608
|
async #existingJournalKey() {
|
|
228
|
-
if (this.#keyPromise !== undefined)
|
|
609
|
+
if (this.#keyPromise !== undefined)
|
|
229
610
|
return this.#keyPromise;
|
|
230
|
-
}
|
|
231
611
|
const keyPath = path.join(this.#rootDirectory, "journal.key");
|
|
232
612
|
try {
|
|
233
613
|
const existing = await readFile(keyPath);
|
|
234
|
-
if (existing.length !== 32)
|
|
614
|
+
if (existing.length !== 32)
|
|
235
615
|
throw new Error("invalid key length");
|
|
236
|
-
}
|
|
237
616
|
this.#keyPromise = Promise.resolve(existing);
|
|
238
617
|
return existing;
|
|
239
618
|
}
|
|
240
619
|
catch {
|
|
241
|
-
throw bridgeError("JOURNAL_KEY_INVALID", "The local
|
|
620
|
+
throw bridgeError("JOURNAL_KEY_INVALID", "The local operation journal key is missing or invalid.", false);
|
|
242
621
|
}
|
|
243
622
|
}
|
|
244
623
|
async #loadOrCreateKey() {
|
|
@@ -246,14 +625,13 @@ export class OperationJournal {
|
|
|
246
625
|
const keyPath = path.join(this.#rootDirectory, "journal.key");
|
|
247
626
|
try {
|
|
248
627
|
const existing = await readFile(keyPath);
|
|
249
|
-
if (existing.length !== 32)
|
|
628
|
+
if (existing.length !== 32)
|
|
250
629
|
throw new Error("invalid key length");
|
|
251
|
-
}
|
|
252
630
|
return existing;
|
|
253
631
|
}
|
|
254
632
|
catch (error) {
|
|
255
633
|
if (error.code !== "ENOENT") {
|
|
256
|
-
throw bridgeError("JOURNAL_KEY_INVALID", "The local
|
|
634
|
+
throw bridgeError("JOURNAL_KEY_INVALID", "The local operation journal key is invalid.", false);
|
|
257
635
|
}
|
|
258
636
|
}
|
|
259
637
|
const key = randomBytes(32);
|
|
@@ -266,9 +644,8 @@ export class OperationJournal {
|
|
|
266
644
|
await handle.close();
|
|
267
645
|
handle = undefined;
|
|
268
646
|
await link(temporaryPath, keyPath);
|
|
269
|
-
if (process.platform !== "win32")
|
|
647
|
+
if (process.platform !== "win32")
|
|
270
648
|
await chmod(keyPath, 0o600);
|
|
271
|
-
}
|
|
272
649
|
await this.#syncDirectory();
|
|
273
650
|
return key;
|
|
274
651
|
}
|
|
@@ -276,11 +653,10 @@ export class OperationJournal {
|
|
|
276
653
|
await closeQuietly(handle);
|
|
277
654
|
if (error.code === "EEXIST") {
|
|
278
655
|
const existing = await readFile(keyPath);
|
|
279
|
-
if (existing.length === 32)
|
|
656
|
+
if (existing.length === 32)
|
|
280
657
|
return existing;
|
|
281
|
-
}
|
|
282
658
|
}
|
|
283
|
-
throw bridgeError("JOURNAL_KEY_WRITE_FAILED", "The local
|
|
659
|
+
throw bridgeError("JOURNAL_KEY_WRITE_FAILED", "The local operation journal key could not be saved.", true);
|
|
284
660
|
}
|
|
285
661
|
finally {
|
|
286
662
|
await unlink(temporaryPath).catch(() => undefined);
|
|
@@ -291,10 +667,7 @@ export class OperationJournal {
|
|
|
291
667
|
const nonce = randomBytes(12);
|
|
292
668
|
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
293
669
|
cipher.setAAD(Buffer.from(clientRequestId, "utf8"));
|
|
294
|
-
const ciphertext = Buffer.concat([
|
|
295
|
-
cipher.update(operationToken, "utf8"),
|
|
296
|
-
cipher.final(),
|
|
297
|
-
]);
|
|
670
|
+
const ciphertext = Buffer.concat([cipher.update(operationToken, "utf8"), cipher.final()]);
|
|
298
671
|
return {
|
|
299
672
|
algorithm: "aes-256-gcm",
|
|
300
673
|
nonce: nonce.toString("base64url"),
|
|
@@ -314,28 +687,14 @@ export class OperationJournal {
|
|
|
314
687
|
]).toString("utf8");
|
|
315
688
|
}
|
|
316
689
|
catch (error) {
|
|
317
|
-
if (error instanceof OmniBridgeError)
|
|
690
|
+
if (error instanceof OmniBridgeError)
|
|
318
691
|
throw error;
|
|
319
|
-
}
|
|
320
692
|
throw bridgeError("JOURNAL_TOKEN_DECRYPT_FAILED", "The local operation token could not be recovered.", false);
|
|
321
693
|
}
|
|
322
694
|
}
|
|
323
|
-
#publicRecord(persisted, operationToken) {
|
|
324
|
-
return {
|
|
325
|
-
clientRequestId: persisted.clientRequestId,
|
|
326
|
-
requestHash: persisted.requestHash,
|
|
327
|
-
operationId: persisted.operationId,
|
|
328
|
-
operationToken,
|
|
329
|
-
uploadUrl: persisted.uploadUrl,
|
|
330
|
-
state: persisted.state,
|
|
331
|
-
createdAt: persisted.createdAt,
|
|
332
|
-
expiresAt: persisted.expiresAt,
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
695
|
async #syncDirectory() {
|
|
336
|
-
if (process.platform === "win32")
|
|
696
|
+
if (process.platform === "win32")
|
|
337
697
|
return;
|
|
338
|
-
}
|
|
339
698
|
let directory;
|
|
340
699
|
try {
|
|
341
700
|
directory = await open(this.#rootDirectory, "r");
|