@stage5/lumine 0.2.54 → 0.2.55
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 +14 -4
- package/lib/admin-workflows.js +759 -59
- package/lib/admin.js +77 -3
- package/lib/auth.js +15 -9
- package/lib/commands.js +1 -1
- package/lib/sponsor.js +9 -8
- package/package.json +1 -1
- package/sdk/LUMINE_ADMIN.md +9 -6
package/README.md
CHANGED
|
@@ -252,6 +252,13 @@ lumine sponsor duty start zero --provider codex \
|
|
|
252
252
|
--model gpt-5.6-sol --effort max --service-tier priority
|
|
253
253
|
```
|
|
254
254
|
|
|
255
|
+
Sponsor commands use the same browser-approval login as the rest of Lumine.
|
|
256
|
+
When no saved CLI login exists, the command opens Twinkle, waits for the person
|
|
257
|
+
using that browser to approve their own account, and then resumes. Any Twinkle
|
|
258
|
+
account may authenticate this way, but login never grants sponsor duty:
|
|
259
|
+
`sponsor duty start` still requires that exact account to have a canonical,
|
|
260
|
+
server-approved sponsor profile.
|
|
261
|
+
|
|
255
262
|
While that foreground process has a fresh server lease, Zero or Ciel exposes a
|
|
256
263
|
Build Workshop in chat and users may see the named sponsor and canonical queue.
|
|
257
264
|
With no live duty, the website stays in its ordinary chat state and shows none
|
|
@@ -383,10 +390,13 @@ includes `error.details.retryIdempotencyKey` so a partial attempt can be
|
|
|
383
390
|
resumed with the exact generated key.
|
|
384
391
|
|
|
385
392
|
Subject and queue listings use opaque, stable snapshot cursors. `--all`
|
|
386
|
-
follows them automatically,
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
393
|
+
follows them automatically, fsyncs each confirmed page to a private NDJSON
|
|
394
|
+
candidate spool, saves only bounded cursor/boundary/count metadata in the
|
|
395
|
+
checkpoint, and records completed queue coverage in the run audit; `--resume`
|
|
396
|
+
verifies the confirmed spool prefix and continues the exact same request. The
|
|
397
|
+
final JSON contract still contains the complete collection, streamed from the
|
|
398
|
+
spool instead of accumulated in memory. With `--all --json`, bounded progress
|
|
399
|
+
goes to stderr so stdout remains one pipe-safe JSON value. Recommendation scans default to the previous completed
|
|
390
400
|
run's start boundary for at-least-once coverage. Use `--after` for an explicit
|
|
391
401
|
timestamp or `--include-legacy`
|
|
392
402
|
for an intentional all-history scan. Subject `--after` is inclusive and
|
package/lib/admin-workflows.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
createReadStream,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
truncateSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
3
17
|
import os from "node:os";
|
|
4
18
|
import path from "node:path";
|
|
19
|
+
import readline from "node:readline";
|
|
5
20
|
|
|
6
21
|
import { writeAdminJsonFile, readAdminJsonFile } from "./admin-news.js";
|
|
7
22
|
import { requestJson } from "./http.js";
|
|
@@ -9,9 +24,14 @@ import { requestJson } from "./http.js";
|
|
|
9
24
|
const MAX_BATCH_TARGET_FILE_BYTES = 2 * 1024 * 1024;
|
|
10
25
|
const MAX_BATCH_TARGETS = 20_000;
|
|
11
26
|
const MAX_AUTOMATIC_PAGES = 100_000;
|
|
12
|
-
const
|
|
13
|
-
const
|
|
27
|
+
const MAX_LEGACY_ADMIN_CHECKPOINT_BYTES = 64 * 1024 * 1024;
|
|
28
|
+
const MAX_PAGINATION_CHECKPOINT_BYTES = 256 * 1024;
|
|
29
|
+
const MAX_BATCH_CHECKPOINT_BYTES = 64 * 1024 * 1024;
|
|
30
|
+
const ADMIN_PAGINATION_SCHEMA_VERSION = 3;
|
|
31
|
+
const LEGACY_ADMIN_PAGINATION_SCHEMA_VERSION = 2;
|
|
14
32
|
const ADMIN_BATCH_SCHEMA_VERSION = 2;
|
|
33
|
+
const PAGINATED_RESULT_STORAGE = Symbol("lumineAdminPaginationStorage");
|
|
34
|
+
const SPOOL_READ_BUFFER_BYTES = 64 * 1024;
|
|
15
35
|
|
|
16
36
|
function validationError(message) {
|
|
17
37
|
const error = new Error(message);
|
|
@@ -59,30 +79,522 @@ function defaultCheckpointPath({ runId, operationName }) {
|
|
|
59
79
|
return path.join(os.tmpdir(), `lumine-admin-run-${runId}-${safeName}.json`);
|
|
60
80
|
}
|
|
61
81
|
|
|
62
|
-
function
|
|
63
|
-
|
|
82
|
+
function operationFingerprintValue({
|
|
83
|
+
schemaVersion,
|
|
84
|
+
options,
|
|
85
|
+
operation,
|
|
86
|
+
runId,
|
|
87
|
+
}) {
|
|
88
|
+
return fingerprint({
|
|
89
|
+
workflowSchemaVersion: schemaVersion,
|
|
90
|
+
runId,
|
|
91
|
+
apiUrl: String(options.apiUrl || "").replace(/\/$/, ""),
|
|
92
|
+
name: operation.name,
|
|
93
|
+
path: pathWithCursor(operation.path, ""),
|
|
94
|
+
pagination: {
|
|
95
|
+
collectionKey: operation.pagination.collectionKey,
|
|
96
|
+
coverageQueue: operation.pagination.coverageQueue || null,
|
|
97
|
+
coverageMode: operation.pagination.coverageMode || null,
|
|
98
|
+
after: operation.pagination.after ?? null,
|
|
99
|
+
filters: operation.pagination.filters || {},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function candidateSpoolPath(checkpointPath) {
|
|
105
|
+
return `${checkpointPath}.candidates-${randomUUID()}.ndjson`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function candidateSpoolBelongsToCheckpoint({ checkpointPath, spoolPath }) {
|
|
109
|
+
const resolvedCheckpoint = path.resolve(checkpointPath);
|
|
110
|
+
const resolvedSpool = path.resolve(String(spoolPath || ""));
|
|
111
|
+
const expectedPrefix = `${path.basename(resolvedCheckpoint)}.candidates-`;
|
|
112
|
+
const spoolName = path.basename(resolvedSpool);
|
|
113
|
+
const spoolIdentifier = spoolName.slice(
|
|
114
|
+
expectedPrefix.length,
|
|
115
|
+
-".ndjson".length,
|
|
116
|
+
);
|
|
117
|
+
return (
|
|
118
|
+
path.dirname(resolvedSpool) === path.dirname(resolvedCheckpoint) &&
|
|
119
|
+
spoolName.startsWith(expectedPrefix) &&
|
|
120
|
+
spoolName.endsWith(".ndjson") &&
|
|
121
|
+
/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(
|
|
122
|
+
spoolIdentifier,
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createCandidateSpool(checkpointPath) {
|
|
128
|
+
const spoolPath = candidateSpoolPath(checkpointPath);
|
|
129
|
+
mkdirSync(path.dirname(spoolPath), { recursive: true });
|
|
130
|
+
const descriptor = openSync(spoolPath, "wx", 0o600);
|
|
131
|
+
closeSync(descriptor);
|
|
132
|
+
chmodSync(spoolPath, 0o600);
|
|
64
133
|
return {
|
|
134
|
+
spoolPath,
|
|
135
|
+
spoolHash: createHash("sha256"),
|
|
136
|
+
spoolBytes: 0,
|
|
137
|
+
spoolSha256: createHash("sha256").digest("hex"),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function loadSupersededCandidateSpool(checkpointPath) {
|
|
142
|
+
let saved;
|
|
143
|
+
try {
|
|
144
|
+
saved = readAdminJsonFile(checkpointPath, "the previous pagination checkpoint", {
|
|
145
|
+
maxBytes: MAX_LEGACY_ADMIN_CHECKPOINT_BYTES,
|
|
146
|
+
});
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const spoolPath = path.resolve(String(saved?.spoolPath || ""));
|
|
151
|
+
if (
|
|
152
|
+
saved?.kind !== "admin-pagination" ||
|
|
153
|
+
Number(saved?.schemaVersion) !== ADMIN_PAGINATION_SCHEMA_VERSION ||
|
|
154
|
+
!candidateSpoolBelongsToCheckpoint({ checkpointPath, spoolPath })
|
|
155
|
+
) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const fileState = lstatSync(spoolPath);
|
|
160
|
+
return fileState.isFile() && !fileState.isSymbolicLink() ? spoolPath : null;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function appendCandidateItems({ spoolPath, spoolHash, items }) {
|
|
167
|
+
const lines = items.map((item) => {
|
|
168
|
+
const serialized = JSON.stringify(item);
|
|
169
|
+
if (serialized === undefined) {
|
|
170
|
+
throw validationError(
|
|
171
|
+
"The API returned a pagination candidate that cannot be serialized.",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return serialized;
|
|
175
|
+
});
|
|
176
|
+
if (lines.length === 0) {
|
|
177
|
+
return { addedBytes: 0, spoolSha256: spoolHash.copy().digest("hex") };
|
|
178
|
+
}
|
|
179
|
+
const payload = Buffer.from(`${lines.join("\n")}\n`, "utf8");
|
|
180
|
+
const descriptor = openSync(spoolPath, "a", 0o600);
|
|
181
|
+
try {
|
|
182
|
+
writeFileSync(descriptor, payload);
|
|
183
|
+
fsyncSync(descriptor);
|
|
184
|
+
} finally {
|
|
185
|
+
closeSync(descriptor);
|
|
186
|
+
}
|
|
187
|
+
spoolHash.update(payload);
|
|
188
|
+
return {
|
|
189
|
+
addedBytes: payload.byteLength,
|
|
190
|
+
spoolSha256: spoolHash.copy().digest("hex"),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeCheckpointCount(value, label) {
|
|
195
|
+
const normalized = Number(value);
|
|
196
|
+
if (!Number.isSafeInteger(normalized) || normalized < 0) {
|
|
197
|
+
throw validationError(`The pagination checkpoint has an invalid ${label}.`);
|
|
198
|
+
}
|
|
199
|
+
return normalized;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeFilterCount(value, label) {
|
|
203
|
+
const normalized = Number(value);
|
|
204
|
+
if (!Number.isSafeInteger(normalized) || normalized < 0) {
|
|
205
|
+
throw validationError(`Pagination has an invalid ${label}.`);
|
|
206
|
+
}
|
|
207
|
+
return normalized;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeClientFilterSummary(value) {
|
|
211
|
+
if (value === null || value === undefined) return null;
|
|
212
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
213
|
+
throw validationError("Pagination has invalid client-filter metadata.");
|
|
214
|
+
}
|
|
215
|
+
if (
|
|
216
|
+
!Array.isArray(value.contentTypes) ||
|
|
217
|
+
value.contentTypes.some(
|
|
218
|
+
(contentType) =>
|
|
219
|
+
typeof contentType !== "string" || !contentType.trim(),
|
|
220
|
+
)
|
|
221
|
+
) {
|
|
222
|
+
throw validationError(
|
|
223
|
+
"Pagination has invalid client-filter content types.",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
contentTypes: value.contentTypes.map((contentType) => contentType.trim()),
|
|
228
|
+
excludedItems: normalizeFilterCount(
|
|
229
|
+
value.excludedItems,
|
|
230
|
+
"client-filter excludedItems count",
|
|
231
|
+
),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function accumulateClientFilterSummary(previous, pageValue) {
|
|
236
|
+
const page = normalizeClientFilterSummary(pageValue);
|
|
237
|
+
if (!page) return previous;
|
|
238
|
+
if (!previous) return page;
|
|
239
|
+
if (
|
|
240
|
+
JSON.stringify(previous.contentTypes) !== JSON.stringify(page.contentTypes)
|
|
241
|
+
) {
|
|
242
|
+
throw validationError(
|
|
243
|
+
"The client-side content filter changed while paging one canonical snapshot.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
...previous,
|
|
248
|
+
excludedItems: normalizeFilterCount(
|
|
249
|
+
previous.excludedItems + page.excludedItems,
|
|
250
|
+
"client-filter aggregate excludedItems count",
|
|
251
|
+
),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function normalizeOperatorViewFilterSummary(value) {
|
|
256
|
+
if (value === null || value === undefined) return null;
|
|
257
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
258
|
+
throw validationError("Pagination has invalid operator-view metadata.");
|
|
259
|
+
}
|
|
260
|
+
const mode = String(value.mode || "").trim();
|
|
261
|
+
if (!mode) {
|
|
262
|
+
throw validationError("Pagination has an invalid operator-view mode.");
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
mode,
|
|
266
|
+
excludedItems: normalizeFilterCount(
|
|
267
|
+
value.excludedItems,
|
|
268
|
+
"operator-view excludedItems count",
|
|
269
|
+
),
|
|
270
|
+
unknownStateItems: normalizeFilterCount(
|
|
271
|
+
value.unknownStateItems,
|
|
272
|
+
"operator-view unknownStateItems count",
|
|
273
|
+
),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function accumulateOperatorViewFilterSummary(previous, pageValue) {
|
|
278
|
+
const page = normalizeOperatorViewFilterSummary(pageValue);
|
|
279
|
+
if (!page) return previous;
|
|
280
|
+
if (!previous) return page;
|
|
281
|
+
if (previous.mode !== page.mode) {
|
|
282
|
+
throw validationError(
|
|
283
|
+
"The operator-view filter changed while paging one canonical snapshot.",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
...previous,
|
|
288
|
+
excludedItems: normalizeFilterCount(
|
|
289
|
+
previous.excludedItems + page.excludedItems,
|
|
290
|
+
"operator-view aggregate excludedItems count",
|
|
291
|
+
),
|
|
292
|
+
unknownStateItems: normalizeFilterCount(
|
|
293
|
+
previous.unknownStateItems + page.unknownStateItems,
|
|
294
|
+
"operator-view aggregate unknownStateItems count",
|
|
295
|
+
),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function verifyCandidateSpool({
|
|
300
|
+
checkpointPath,
|
|
301
|
+
spoolPath,
|
|
302
|
+
spoolBytes,
|
|
303
|
+
spoolSha256,
|
|
304
|
+
candidateCount,
|
|
305
|
+
discardUnconfirmedTail,
|
|
306
|
+
}) {
|
|
307
|
+
if (!candidateSpoolBelongsToCheckpoint({ checkpointPath, spoolPath })) {
|
|
308
|
+
throw validationError(
|
|
309
|
+
"The pagination checkpoint references an invalid candidate spool.",
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
const confirmedBytes = normalizeCheckpointCount(spoolBytes, "spoolBytes");
|
|
313
|
+
const confirmedCount = normalizeCheckpointCount(
|
|
314
|
+
candidateCount,
|
|
315
|
+
"candidateCount",
|
|
316
|
+
);
|
|
317
|
+
const expectedDigest = String(spoolSha256 || "").toLowerCase();
|
|
318
|
+
if (!/^[a-f0-9]{64}$/.test(expectedDigest)) {
|
|
319
|
+
throw validationError(
|
|
320
|
+
"The pagination checkpoint has an invalid candidate-spool digest.",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
let fileState;
|
|
324
|
+
try {
|
|
325
|
+
fileState = lstatSync(spoolPath);
|
|
326
|
+
} catch {
|
|
327
|
+
throw validationError(`Could not read pagination candidates at ${spoolPath}.`);
|
|
328
|
+
}
|
|
329
|
+
if (!fileState.isFile() || fileState.isSymbolicLink()) {
|
|
330
|
+
throw validationError(
|
|
331
|
+
"The pagination candidate spool must be a regular private file.",
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (fileState.size < confirmedBytes) {
|
|
335
|
+
throw validationError(
|
|
336
|
+
"The pagination candidate spool is shorter than its confirmed checkpoint.",
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (fileState.size > confirmedBytes) {
|
|
340
|
+
if (!discardUnconfirmedTail) {
|
|
341
|
+
throw validationError(
|
|
342
|
+
"The pagination candidate spool changed after its checkpoint was confirmed.",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
truncateSync(spoolPath, confirmedBytes);
|
|
346
|
+
}
|
|
347
|
+
chmodSync(spoolPath, 0o600);
|
|
348
|
+
const spoolHash = createHash("sha256");
|
|
349
|
+
const descriptor = openSync(spoolPath, "r");
|
|
350
|
+
const buffer = Buffer.allocUnsafe(SPOOL_READ_BUFFER_BYTES);
|
|
351
|
+
let remaining = confirmedBytes;
|
|
352
|
+
let lineCount = 0;
|
|
353
|
+
try {
|
|
354
|
+
while (remaining > 0) {
|
|
355
|
+
const bytesRead = readSync(
|
|
356
|
+
descriptor,
|
|
357
|
+
buffer,
|
|
358
|
+
0,
|
|
359
|
+
Math.min(buffer.length, remaining),
|
|
360
|
+
null,
|
|
361
|
+
);
|
|
362
|
+
if (bytesRead <= 0) {
|
|
363
|
+
throw validationError(
|
|
364
|
+
"The pagination candidate spool ended before its confirmed boundary.",
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const chunk = buffer.subarray(0, bytesRead);
|
|
368
|
+
spoolHash.update(chunk);
|
|
369
|
+
for (let index = 0; index < bytesRead; index += 1) {
|
|
370
|
+
if (chunk[index] === 10) lineCount += 1;
|
|
371
|
+
}
|
|
372
|
+
remaining -= bytesRead;
|
|
373
|
+
}
|
|
374
|
+
} finally {
|
|
375
|
+
closeSync(descriptor);
|
|
376
|
+
}
|
|
377
|
+
if (lineCount !== confirmedCount) {
|
|
378
|
+
throw validationError(
|
|
379
|
+
"The pagination candidate spool count does not match its checkpoint.",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (spoolHash.copy().digest("hex") !== expectedDigest) {
|
|
383
|
+
throw validationError(
|
|
384
|
+
"The pagination candidate spool does not match its confirmed checkpoint digest.",
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
return spoolHash;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function writePaginationCheckpoint(checkpointPath, state) {
|
|
391
|
+
const { resumed: _resumed, ...checkpoint } = state;
|
|
392
|
+
writeAdminJsonFile(
|
|
393
|
+
checkpointPath,
|
|
394
|
+
{ ...checkpoint, updatedAt: new Date().toISOString() },
|
|
395
|
+
{ privateFile: true, maxBytes: MAX_PAGINATION_CHECKPOINT_BYTES },
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function paginationStorageFromResult(result) {
|
|
400
|
+
return result?.[PAGINATED_RESULT_STORAGE] || null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function getPaginatedResultStorage(result) {
|
|
404
|
+
const storage = paginationStorageFromResult(result);
|
|
405
|
+
return storage ? { ...storage } : null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function attachPaginationStorage(result, storage) {
|
|
409
|
+
Object.defineProperty(result, PAGINATED_RESULT_STORAGE, {
|
|
410
|
+
configurable: false,
|
|
411
|
+
enumerable: false,
|
|
412
|
+
writable: false,
|
|
413
|
+
value: Object.freeze({ ...storage }),
|
|
414
|
+
});
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function serializedObjectEntries(value, excludedKeys = new Set()) {
|
|
419
|
+
const entries = [];
|
|
420
|
+
for (const [key, entryValue] of Object.entries(value || {})) {
|
|
421
|
+
if (excludedKeys.has(key)) continue;
|
|
422
|
+
const serialized = JSON.stringify(entryValue);
|
|
423
|
+
if (serialized !== undefined) {
|
|
424
|
+
entries.push(`${JSON.stringify(key)}:${serialized}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return entries;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function forEachPaginatedResultItem(result, visit) {
|
|
431
|
+
const storage = paginationStorageFromResult(result);
|
|
432
|
+
if (!storage) {
|
|
433
|
+
throw validationError("The result does not contain spooled pagination data.");
|
|
434
|
+
}
|
|
435
|
+
verifyCandidateSpool({ ...storage, discardUnconfirmedTail: false });
|
|
436
|
+
const input = createReadStream(storage.spoolPath, { encoding: "utf8" });
|
|
437
|
+
const observedHash = createHash("sha256");
|
|
438
|
+
let observedBytes = 0;
|
|
439
|
+
input.on("data", (chunk) => {
|
|
440
|
+
observedHash.update(chunk, "utf8");
|
|
441
|
+
observedBytes += Buffer.byteLength(chunk, "utf8");
|
|
442
|
+
});
|
|
443
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
444
|
+
let index = 0;
|
|
445
|
+
try {
|
|
446
|
+
for await (const line of lines) {
|
|
447
|
+
if (!line) continue;
|
|
448
|
+
let item;
|
|
449
|
+
try {
|
|
450
|
+
item = JSON.parse(line);
|
|
451
|
+
} catch {
|
|
452
|
+
throw validationError(
|
|
453
|
+
"The pagination candidate spool contains invalid JSON.",
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
await visit(item, index);
|
|
457
|
+
index += 1;
|
|
458
|
+
}
|
|
459
|
+
} finally {
|
|
460
|
+
lines.close();
|
|
461
|
+
input.destroy();
|
|
462
|
+
}
|
|
463
|
+
if (index !== storage.candidateCount) {
|
|
464
|
+
throw validationError(
|
|
465
|
+
"The pagination candidate spool changed while it was being read.",
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
if (
|
|
469
|
+
observedBytes !== storage.spoolBytes ||
|
|
470
|
+
observedHash.digest("hex") !== storage.spoolSha256
|
|
471
|
+
) {
|
|
472
|
+
throw validationError(
|
|
473
|
+
"The pagination candidate spool changed while it was being read.",
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export async function writePaginatedResultJson({ result, write }) {
|
|
479
|
+
const storage = paginationStorageFromResult(result);
|
|
480
|
+
if (!storage) {
|
|
481
|
+
await write(`${JSON.stringify(result)}\n`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const outerEntries = serializedObjectEntries(result, new Set(["data"]));
|
|
485
|
+
const dataEntries = serializedObjectEntries(
|
|
486
|
+
result.data,
|
|
487
|
+
new Set([storage.collectionKey]),
|
|
488
|
+
);
|
|
489
|
+
let prefix = "{";
|
|
490
|
+
if (outerEntries.length > 0) prefix += outerEntries.join(",");
|
|
491
|
+
if (outerEntries.length > 0) prefix += ",";
|
|
492
|
+
prefix += `${JSON.stringify("data")}:{`;
|
|
493
|
+
if (dataEntries.length > 0) prefix += `${dataEntries.join(",")},`;
|
|
494
|
+
prefix += `${JSON.stringify(storage.collectionKey)}:[`;
|
|
495
|
+
await write(prefix);
|
|
496
|
+
let wroteItem = false;
|
|
497
|
+
await forEachPaginatedResultItem(result, async (item) => {
|
|
498
|
+
await write(`${wroteItem ? "," : ""}${JSON.stringify(item)}`);
|
|
499
|
+
wroteItem = true;
|
|
500
|
+
});
|
|
501
|
+
await write("]}}\n");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function writePaginatedResultFile(filePath, result) {
|
|
505
|
+
const resolved = path.resolve(String(filePath || "").trim());
|
|
506
|
+
if (!String(filePath || "").trim()) {
|
|
507
|
+
throw validationError("An output file path is required.");
|
|
508
|
+
}
|
|
509
|
+
mkdirSync(path.dirname(resolved), { recursive: true });
|
|
510
|
+
const temporary = `${resolved}.tmp-${process.pid}-${randomUUID()}`;
|
|
511
|
+
let descriptor;
|
|
512
|
+
try {
|
|
513
|
+
descriptor = openSync(temporary, "wx", 0o600);
|
|
514
|
+
await writePaginatedResultJson({
|
|
515
|
+
result,
|
|
516
|
+
write: async (chunk) => writeFileSync(descriptor, chunk, "utf8"),
|
|
517
|
+
});
|
|
518
|
+
fsyncSync(descriptor);
|
|
519
|
+
closeSync(descriptor);
|
|
520
|
+
descriptor = undefined;
|
|
521
|
+
renameSync(temporary, resolved);
|
|
522
|
+
chmodSync(resolved, 0o600);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (descriptor !== undefined) {
|
|
525
|
+
try {
|
|
526
|
+
closeSync(descriptor);
|
|
527
|
+
} catch {
|
|
528
|
+
// The failed write may already have closed the descriptor.
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
unlinkSync(temporary);
|
|
533
|
+
} catch {
|
|
534
|
+
// Exclusive create or successful rename may leave nothing to clean.
|
|
535
|
+
}
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
return resolved;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function aggregatePageResult({ operation, lastPage, checkpointPath, state }) {
|
|
542
|
+
const last = lastPage || { ok: true, status: "success", data: {} };
|
|
543
|
+
const lastData = { ...(last.data || {}) };
|
|
544
|
+
delete lastData[operation.pagination.collectionKey];
|
|
545
|
+
if (state.filterSummariesComplete) {
|
|
546
|
+
if (state.clientFilter) lastData.clientFilter = state.clientFilter;
|
|
547
|
+
else delete lastData.clientFilter;
|
|
548
|
+
if (state.operatorViewFilter) {
|
|
549
|
+
lastData.operatorViewFilter = state.operatorViewFilter;
|
|
550
|
+
} else {
|
|
551
|
+
delete lastData.operatorViewFilter;
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
// Legacy v2 checkpoints stored filtered candidates but not exact per-page
|
|
555
|
+
// exclusion counters. Do not present a last-page subtotal as a full-scan
|
|
556
|
+
// total after migrating one of those checkpoints.
|
|
557
|
+
delete lastData.clientFilter;
|
|
558
|
+
delete lastData.operatorViewFilter;
|
|
559
|
+
}
|
|
560
|
+
const candidateStorage = {
|
|
561
|
+
format: "ndjson",
|
|
562
|
+
path: state.spoolPath,
|
|
563
|
+
byteLength: state.spoolBytes,
|
|
564
|
+
sha256: state.spoolSha256,
|
|
565
|
+
};
|
|
566
|
+
const result = {
|
|
65
567
|
...last,
|
|
66
568
|
data: {
|
|
67
|
-
...
|
|
68
|
-
[operation.pagination.collectionKey]: state.items,
|
|
569
|
+
...lastData,
|
|
69
570
|
pagination: {
|
|
70
571
|
...(last.data?.pagination || {}),
|
|
71
572
|
nextCursor: state.nextCursor,
|
|
72
573
|
hasMore: !state.exhausted,
|
|
73
574
|
exhausted: state.exhausted,
|
|
74
575
|
after: state.after,
|
|
576
|
+
snapshotMaxId: state.snapshotMaxId,
|
|
75
577
|
snapshotTimeStamp: state.snapshotTimeStamp,
|
|
76
578
|
},
|
|
77
579
|
scan: {
|
|
78
580
|
pages: state.pages,
|
|
79
581
|
scannedCount: state.scannedCount,
|
|
80
|
-
candidateCount: state.
|
|
582
|
+
candidateCount: state.candidateCount,
|
|
81
583
|
checkpointPath,
|
|
82
584
|
resumed: state.resumed,
|
|
585
|
+
filterSummariesComplete: state.filterSummariesComplete,
|
|
586
|
+
candidateStorage,
|
|
83
587
|
},
|
|
84
588
|
},
|
|
85
589
|
};
|
|
590
|
+
return attachPaginationStorage(result, {
|
|
591
|
+
checkpointPath,
|
|
592
|
+
collectionKey: operation.pagination.collectionKey,
|
|
593
|
+
spoolPath: state.spoolPath,
|
|
594
|
+
spoolBytes: state.spoolBytes,
|
|
595
|
+
spoolSha256: state.spoolSha256,
|
|
596
|
+
candidateCount: state.candidateCount,
|
|
597
|
+
});
|
|
86
598
|
}
|
|
87
599
|
|
|
88
600
|
export async function runAutomaticPagination({
|
|
@@ -101,19 +613,29 @@ export async function runAutomaticPagination({
|
|
|
101
613
|
options.adminCheckpoint ||
|
|
102
614
|
defaultCheckpointPath({ runId, operationName: operation.name }),
|
|
103
615
|
);
|
|
104
|
-
const
|
|
105
|
-
|
|
616
|
+
const requestedOutputPath = String(options.adminOutput || "").trim();
|
|
617
|
+
if (options.adminOutput && !requestedOutputPath) {
|
|
618
|
+
throw validationError("An output file path is required.");
|
|
619
|
+
}
|
|
620
|
+
const outputPath = requestedOutputPath
|
|
621
|
+
? path.resolve(requestedOutputPath)
|
|
622
|
+
: null;
|
|
623
|
+
if (outputPath === checkpointPath) {
|
|
624
|
+
throw validationError(
|
|
625
|
+
"The pagination checkpoint and result output must use different files.",
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
const operationFingerprint = operationFingerprintValue({
|
|
629
|
+
schemaVersion: ADMIN_PAGINATION_SCHEMA_VERSION,
|
|
630
|
+
options,
|
|
631
|
+
operation,
|
|
632
|
+
runId,
|
|
633
|
+
});
|
|
634
|
+
const legacyOperationFingerprint = operationFingerprintValue({
|
|
635
|
+
schemaVersion: LEGACY_ADMIN_PAGINATION_SCHEMA_VERSION,
|
|
636
|
+
options,
|
|
637
|
+
operation,
|
|
106
638
|
runId,
|
|
107
|
-
apiUrl: String(options.apiUrl || "").replace(/\/$/, ""),
|
|
108
|
-
name: operation.name,
|
|
109
|
-
path: pathWithCursor(operation.path, ""),
|
|
110
|
-
pagination: {
|
|
111
|
-
collectionKey: operation.pagination.collectionKey,
|
|
112
|
-
coverageQueue: operation.pagination.coverageQueue || null,
|
|
113
|
-
coverageMode: operation.pagination.coverageMode || null,
|
|
114
|
-
after: operation.pagination.after ?? null,
|
|
115
|
-
filters: operation.pagination.filters || {},
|
|
116
|
-
},
|
|
117
639
|
});
|
|
118
640
|
let state = {
|
|
119
641
|
schemaVersion: ADMIN_PAGINATION_SCHEMA_VERSION,
|
|
@@ -129,29 +651,188 @@ export async function runAutomaticPagination({
|
|
|
129
651
|
snapshotTimeStamp: null,
|
|
130
652
|
after: operation.pagination.after ?? null,
|
|
131
653
|
boundariesConfirmed: false,
|
|
132
|
-
|
|
654
|
+
candidateCount: 0,
|
|
655
|
+
spoolPath: "",
|
|
656
|
+
spoolBytes: 0,
|
|
657
|
+
spoolSha256: "",
|
|
658
|
+
filterSummariesComplete: true,
|
|
659
|
+
clientFilter: null,
|
|
660
|
+
operatorViewFilter: null,
|
|
133
661
|
resumed: false,
|
|
134
662
|
updatedAt: new Date().toISOString(),
|
|
135
663
|
};
|
|
664
|
+
let spoolHash;
|
|
136
665
|
if (options.adminResume) {
|
|
137
666
|
const saved = readAdminJsonFile(
|
|
138
667
|
checkpointPath,
|
|
139
668
|
"the pagination checkpoint",
|
|
140
|
-
{ maxBytes:
|
|
669
|
+
{ maxBytes: MAX_LEGACY_ADMIN_CHECKPOINT_BYTES },
|
|
670
|
+
);
|
|
671
|
+
const savedSchemaVersion = Number(saved?.schemaVersion);
|
|
672
|
+
const savedPages = normalizeCheckpointCount(saved?.pages, "pages");
|
|
673
|
+
const savedScannedCount = normalizeCheckpointCount(
|
|
674
|
+
saved?.scannedCount,
|
|
675
|
+
"scannedCount",
|
|
141
676
|
);
|
|
677
|
+
const baseMatches =
|
|
678
|
+
saved?.kind === "admin-pagination" &&
|
|
679
|
+
Number(saved?.runId) === runId &&
|
|
680
|
+
((savedPages === 0 && saved?.boundariesConfirmed === false) ||
|
|
681
|
+
(savedPages > 0 && saved?.boundariesConfirmed === true));
|
|
682
|
+
const currentMatches =
|
|
683
|
+
savedSchemaVersion === ADMIN_PAGINATION_SCHEMA_VERSION &&
|
|
684
|
+
saved?.operationFingerprint === operationFingerprint;
|
|
685
|
+
const legacyMatches =
|
|
686
|
+
savedSchemaVersion === LEGACY_ADMIN_PAGINATION_SCHEMA_VERSION &&
|
|
687
|
+
saved?.operationFingerprint === legacyOperationFingerprint &&
|
|
688
|
+
Array.isArray(saved?.items) &&
|
|
689
|
+
savedPages > 0;
|
|
690
|
+
if (!baseMatches || (!currentMatches && !legacyMatches)) {
|
|
691
|
+
throw validationError(
|
|
692
|
+
"The checkpoint does not belong to this run and exact listing request.",
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
const savedNextCursor = String(saved?.nextCursor || "");
|
|
696
|
+
const savedExhausted = saved?.exhausted;
|
|
142
697
|
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
Number(saved?.runId) !== runId ||
|
|
147
|
-
!Array.isArray(saved?.items) ||
|
|
148
|
-
saved?.boundariesConfirmed !== true
|
|
698
|
+
typeof savedExhausted !== "boolean" ||
|
|
699
|
+
(!savedExhausted && savedPages > 0 && !savedNextCursor) ||
|
|
700
|
+
(savedExhausted && savedNextCursor)
|
|
149
701
|
) {
|
|
150
702
|
throw validationError(
|
|
151
|
-
"The checkpoint
|
|
703
|
+
"The pagination checkpoint has inconsistent exhaustion metadata.",
|
|
152
704
|
);
|
|
153
705
|
}
|
|
154
|
-
|
|
706
|
+
if (legacyMatches) {
|
|
707
|
+
const createdSpool = createCandidateSpool(checkpointPath);
|
|
708
|
+
spoolHash = createdSpool.spoolHash;
|
|
709
|
+
let spoolBytes = 0;
|
|
710
|
+
let spoolSha256 = createdSpool.spoolSha256;
|
|
711
|
+
try {
|
|
712
|
+
for (let index = 0; index < saved.items.length; index += 250) {
|
|
713
|
+
const appended = appendCandidateItems({
|
|
714
|
+
spoolPath: createdSpool.spoolPath,
|
|
715
|
+
spoolHash,
|
|
716
|
+
items: saved.items.slice(index, index + 250),
|
|
717
|
+
});
|
|
718
|
+
spoolBytes += appended.addedBytes;
|
|
719
|
+
spoolSha256 = appended.spoolSha256;
|
|
720
|
+
}
|
|
721
|
+
state = {
|
|
722
|
+
...state,
|
|
723
|
+
nextCursor: savedNextCursor,
|
|
724
|
+
exhausted: savedExhausted,
|
|
725
|
+
pages: savedPages,
|
|
726
|
+
scannedCount: savedScannedCount,
|
|
727
|
+
snapshotMaxId: normalizeBoundary(
|
|
728
|
+
saved?.snapshotMaxId,
|
|
729
|
+
"snapshotMaxId",
|
|
730
|
+
),
|
|
731
|
+
snapshotTimeStamp: normalizeBoundary(
|
|
732
|
+
saved?.snapshotTimeStamp,
|
|
733
|
+
"snapshotTimeStamp",
|
|
734
|
+
),
|
|
735
|
+
after: normalizeBoundary(saved?.after, "after"),
|
|
736
|
+
boundariesConfirmed: true,
|
|
737
|
+
candidateCount: saved.items.length,
|
|
738
|
+
spoolPath: createdSpool.spoolPath,
|
|
739
|
+
spoolBytes,
|
|
740
|
+
spoolSha256,
|
|
741
|
+
filterSummariesComplete: false,
|
|
742
|
+
resumed: true,
|
|
743
|
+
};
|
|
744
|
+
writePaginationCheckpoint(checkpointPath, state);
|
|
745
|
+
} catch (error) {
|
|
746
|
+
try {
|
|
747
|
+
unlinkSync(createdSpool.spoolPath);
|
|
748
|
+
} catch {
|
|
749
|
+
// A failed migration may have created no durable candidate spool.
|
|
750
|
+
}
|
|
751
|
+
throw error;
|
|
752
|
+
}
|
|
753
|
+
} else {
|
|
754
|
+
const candidateCount = normalizeCheckpointCount(
|
|
755
|
+
saved?.candidateCount,
|
|
756
|
+
"candidateCount",
|
|
757
|
+
);
|
|
758
|
+
state = {
|
|
759
|
+
...state,
|
|
760
|
+
nextCursor: savedNextCursor,
|
|
761
|
+
exhausted: savedExhausted,
|
|
762
|
+
pages: savedPages,
|
|
763
|
+
scannedCount: savedScannedCount,
|
|
764
|
+
snapshotMaxId:
|
|
765
|
+
savedPages > 0
|
|
766
|
+
? normalizeBoundary(saved?.snapshotMaxId, "snapshotMaxId")
|
|
767
|
+
: null,
|
|
768
|
+
snapshotTimeStamp:
|
|
769
|
+
savedPages > 0
|
|
770
|
+
? normalizeBoundary(
|
|
771
|
+
saved?.snapshotTimeStamp,
|
|
772
|
+
"snapshotTimeStamp",
|
|
773
|
+
)
|
|
774
|
+
: null,
|
|
775
|
+
after:
|
|
776
|
+
savedPages > 0
|
|
777
|
+
? normalizeBoundary(saved?.after, "after")
|
|
778
|
+
: operation.pagination.after ?? null,
|
|
779
|
+
boundariesConfirmed: saved?.boundariesConfirmed,
|
|
780
|
+
candidateCount,
|
|
781
|
+
spoolPath: path.resolve(String(saved?.spoolPath || "")),
|
|
782
|
+
spoolBytes: normalizeCheckpointCount(saved?.spoolBytes, "spoolBytes"),
|
|
783
|
+
spoolSha256: String(saved?.spoolSha256 || "").toLowerCase(),
|
|
784
|
+
filterSummariesComplete:
|
|
785
|
+
savedPages === 0 || saved?.filterSummariesComplete === true,
|
|
786
|
+
clientFilter:
|
|
787
|
+
savedPages > 0 && saved?.filterSummariesComplete === true
|
|
788
|
+
? normalizeClientFilterSummary(saved?.clientFilter)
|
|
789
|
+
: null,
|
|
790
|
+
operatorViewFilter:
|
|
791
|
+
savedPages > 0 && saved?.filterSummariesComplete === true
|
|
792
|
+
? normalizeOperatorViewFilterSummary(saved?.operatorViewFilter)
|
|
793
|
+
: null,
|
|
794
|
+
resumed: true,
|
|
795
|
+
};
|
|
796
|
+
spoolHash = verifyCandidateSpool({
|
|
797
|
+
checkpointPath,
|
|
798
|
+
spoolPath: state.spoolPath,
|
|
799
|
+
spoolBytes: state.spoolBytes,
|
|
800
|
+
spoolSha256: state.spoolSha256,
|
|
801
|
+
candidateCount: state.candidateCount,
|
|
802
|
+
discardUnconfirmedTail: true,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
} else {
|
|
806
|
+
const supersededSpoolPath = loadSupersededCandidateSpool(checkpointPath);
|
|
807
|
+
const createdSpool = createCandidateSpool(checkpointPath);
|
|
808
|
+
spoolHash = createdSpool.spoolHash;
|
|
809
|
+
state.spoolPath = createdSpool.spoolPath;
|
|
810
|
+
state.spoolBytes = createdSpool.spoolBytes;
|
|
811
|
+
state.spoolSha256 = createdSpool.spoolSha256;
|
|
812
|
+
try {
|
|
813
|
+
writePaginationCheckpoint(checkpointPath, state);
|
|
814
|
+
} catch (error) {
|
|
815
|
+
try {
|
|
816
|
+
unlinkSync(state.spoolPath);
|
|
817
|
+
} catch {
|
|
818
|
+
// The failed initialization may leave no spool to clean.
|
|
819
|
+
}
|
|
820
|
+
throw error;
|
|
821
|
+
}
|
|
822
|
+
if (supersededSpoolPath && supersededSpoolPath !== state.spoolPath) {
|
|
823
|
+
try {
|
|
824
|
+
unlinkSync(supersededSpoolPath);
|
|
825
|
+
} catch (error) {
|
|
826
|
+
reportProgress(
|
|
827
|
+
`Lumine admin ${operation.name}: could not remove superseded candidate spool ${supersededSpoolPath} (${error?.message || error}).`,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (outputPath === path.resolve(state.spoolPath)) {
|
|
833
|
+
throw validationError(
|
|
834
|
+
"The pagination candidate spool and result output must use different files.",
|
|
835
|
+
);
|
|
155
836
|
}
|
|
156
837
|
const progressEnabled = options.json === true;
|
|
157
838
|
if (progressEnabled) {
|
|
@@ -159,17 +840,17 @@ export async function runAutomaticPagination({
|
|
|
159
840
|
`Lumine admin ${operation.name}: ${state.resumed ? "resuming" : "starting"} canonical scan; checkpoint ${checkpointPath}.`,
|
|
160
841
|
);
|
|
161
842
|
}
|
|
162
|
-
|
|
843
|
+
let lastPage = null;
|
|
163
844
|
while (!state.exhausted) {
|
|
164
845
|
if (state.pages >= MAX_AUTOMATIC_PAGES) {
|
|
165
846
|
throw validationError(
|
|
166
|
-
`Automatic pagination stopped at the ${MAX_AUTOMATIC_PAGES}-page safety ceiling.
|
|
847
|
+
`Automatic pagination stopped at the ${MAX_AUTOMATIC_PAGES}-page safety ceiling. Start a narrower request with a new checkpoint.`,
|
|
167
848
|
);
|
|
168
849
|
}
|
|
169
850
|
const requestPath = pathWithCursor(operation.path, state.nextCursor);
|
|
170
851
|
const raw = await fetchPage(requestPath);
|
|
171
852
|
const page = transformPage(raw);
|
|
172
|
-
|
|
853
|
+
lastPage = page;
|
|
173
854
|
const data = page?.data || {};
|
|
174
855
|
const pagination = data.pagination;
|
|
175
856
|
if (!pagination || typeof pagination !== "object" || Array.isArray(pagination)) {
|
|
@@ -224,31 +905,52 @@ export async function runAutomaticPagination({
|
|
|
224
905
|
state.after = requestedAfter ?? pageAfter;
|
|
225
906
|
state.boundariesConfirmed = true;
|
|
226
907
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
items.length,
|
|
232
|
-
);
|
|
233
|
-
state.nextCursor = nextCursor;
|
|
234
|
-
state.exhausted = pagination.exhausted;
|
|
235
|
-
state.updatedAt = new Date().toISOString();
|
|
236
|
-
writeAdminJsonFile(checkpointPath, state, {
|
|
237
|
-
privateFile: true,
|
|
238
|
-
maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
|
|
908
|
+
const appended = appendCandidateItems({
|
|
909
|
+
spoolPath: state.spoolPath,
|
|
910
|
+
spoolHash,
|
|
911
|
+
items,
|
|
239
912
|
});
|
|
913
|
+
const clientFilter = state.filterSummariesComplete
|
|
914
|
+
? accumulateClientFilterSummary(state.clientFilter, data.clientFilter)
|
|
915
|
+
: null;
|
|
916
|
+
const operatorViewFilter = state.filterSummariesComplete
|
|
917
|
+
? accumulateOperatorViewFilterSummary(
|
|
918
|
+
state.operatorViewFilter,
|
|
919
|
+
data.operatorViewFilter,
|
|
920
|
+
)
|
|
921
|
+
: null;
|
|
922
|
+
const nextState = {
|
|
923
|
+
...state,
|
|
924
|
+
pages: state.pages + 1,
|
|
925
|
+
scannedCount:
|
|
926
|
+
state.scannedCount +
|
|
927
|
+
normalizeScannedCount(
|
|
928
|
+
pagination.scannedCount,
|
|
929
|
+
items.length,
|
|
930
|
+
),
|
|
931
|
+
candidateCount: state.candidateCount + items.length,
|
|
932
|
+
spoolBytes: state.spoolBytes + appended.addedBytes,
|
|
933
|
+
spoolSha256: appended.spoolSha256,
|
|
934
|
+
clientFilter,
|
|
935
|
+
operatorViewFilter,
|
|
936
|
+
nextCursor,
|
|
937
|
+
exhausted: pagination.exhausted,
|
|
938
|
+
updatedAt: new Date().toISOString(),
|
|
939
|
+
};
|
|
940
|
+
writePaginationCheckpoint(checkpointPath, nextState);
|
|
941
|
+
state = nextState;
|
|
240
942
|
if (
|
|
241
943
|
progressEnabled &&
|
|
242
|
-
(pages
|
|
944
|
+
(state.pages === 1 || state.pages % 10 === 0 || state.exhausted)
|
|
243
945
|
) {
|
|
244
946
|
reportProgress(
|
|
245
|
-
`Lumine admin ${operation.name}: ${state.pages} page(s), ${state.scannedCount} row(s) scanned, ${state.
|
|
947
|
+
`Lumine admin ${operation.name}: ${state.pages} page(s), ${state.scannedCount} row(s) scanned, ${state.candidateCount} candidate(s)${state.exhausted ? "; canonical snapshot exhausted." : "; continuing."}`,
|
|
246
948
|
);
|
|
247
949
|
}
|
|
248
950
|
}
|
|
249
951
|
const result = aggregatePageResult({
|
|
250
952
|
operation,
|
|
251
|
-
|
|
953
|
+
lastPage,
|
|
252
954
|
checkpointPath,
|
|
253
955
|
state,
|
|
254
956
|
});
|
|
@@ -259,18 +961,16 @@ export async function runAutomaticPagination({
|
|
|
259
961
|
after: state.after,
|
|
260
962
|
pages: state.pages,
|
|
261
963
|
scannedCount: state.scannedCount,
|
|
262
|
-
candidateCount: state.
|
|
964
|
+
candidateCount: state.candidateCount,
|
|
263
965
|
snapshotMaxId: state.snapshotMaxId,
|
|
264
966
|
snapshotTimeStamp: state.snapshotTimeStamp,
|
|
265
967
|
exhausted: state.exhausted,
|
|
266
968
|
filters: operation.pagination.filters || {},
|
|
267
969
|
});
|
|
268
970
|
}
|
|
269
|
-
if (
|
|
270
|
-
result.data.scan.outputPath =
|
|
271
|
-
|
|
272
|
-
maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
|
|
273
|
-
});
|
|
971
|
+
if (outputPath) {
|
|
972
|
+
result.data.scan.outputPath = outputPath;
|
|
973
|
+
await writePaginatedResultFile(outputPath, result);
|
|
274
974
|
}
|
|
275
975
|
return result;
|
|
276
976
|
}
|
|
@@ -365,7 +1065,7 @@ export async function runBatchSkips({
|
|
|
365
1065
|
};
|
|
366
1066
|
if (options.adminResume) {
|
|
367
1067
|
const saved = readAdminJsonFile(checkpointPath, "the batch checkpoint", {
|
|
368
|
-
maxBytes:
|
|
1068
|
+
maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
|
|
369
1069
|
});
|
|
370
1070
|
if (
|
|
371
1071
|
saved?.schemaVersion !== ADMIN_BATCH_SCHEMA_VERSION ||
|
|
@@ -381,7 +1081,7 @@ export async function runBatchSkips({
|
|
|
381
1081
|
} else {
|
|
382
1082
|
writeAdminJsonFile(checkpointPath, state, {
|
|
383
1083
|
privateFile: true,
|
|
384
|
-
maxBytes:
|
|
1084
|
+
maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
|
|
385
1085
|
});
|
|
386
1086
|
}
|
|
387
1087
|
for (const target of targets) {
|
|
@@ -405,7 +1105,7 @@ export async function runBatchSkips({
|
|
|
405
1105
|
state.updatedAt = new Date().toISOString();
|
|
406
1106
|
writeAdminJsonFile(checkpointPath, state, {
|
|
407
1107
|
privateFile: true,
|
|
408
|
-
maxBytes:
|
|
1108
|
+
maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
|
|
409
1109
|
});
|
|
410
1110
|
}
|
|
411
1111
|
const result = {
|
|
@@ -427,7 +1127,7 @@ export async function runBatchSkips({
|
|
|
427
1127
|
if (options.adminOutput) {
|
|
428
1128
|
result.data.batch.outputPath = writeAdminJsonFile(options.adminOutput, result, {
|
|
429
1129
|
privateFile: true,
|
|
430
|
-
maxBytes:
|
|
1130
|
+
maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
|
|
431
1131
|
});
|
|
432
1132
|
}
|
|
433
1133
|
return result;
|
package/lib/admin.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { once } from "node:events";
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
import { assertAuthScope, resolveAuth } from "./auth.js";
|
|
4
5
|
import { requestJson } from "./http.js";
|
|
@@ -8,7 +9,13 @@ import {
|
|
|
8
9
|
validateNewsEditorial,
|
|
9
10
|
writeNewsClaimArtifacts,
|
|
10
11
|
} from "./admin-news.js";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
forEachPaginatedResultItem,
|
|
14
|
+
getPaginatedResultStorage,
|
|
15
|
+
runAutomaticPagination,
|
|
16
|
+
runBatchSkips,
|
|
17
|
+
writePaginatedResultJson,
|
|
18
|
+
} from "./admin-workflows.js";
|
|
12
19
|
import {
|
|
13
20
|
parseBuildReviewReceipt,
|
|
14
21
|
runManagedBuildReview,
|
|
@@ -380,9 +387,25 @@ export function assertRecommendationWindowResult({ operation, result }) {
|
|
|
380
387
|
}
|
|
381
388
|
}
|
|
382
389
|
|
|
383
|
-
function finishAdminOutput({ options, operation, result }) {
|
|
390
|
+
async function finishAdminOutput({ options, operation, result }) {
|
|
391
|
+
const paginationStorage = getPaginatedResultStorage(result);
|
|
384
392
|
if (options.json) {
|
|
385
|
-
|
|
393
|
+
if (paginationStorage) {
|
|
394
|
+
await writePaginatedResultJson({
|
|
395
|
+
result,
|
|
396
|
+
write: async (chunk) => {
|
|
397
|
+
if (!process.stdout.write(chunk)) {
|
|
398
|
+
await once(process.stdout, "drain");
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
} else {
|
|
403
|
+
console.log(JSON.stringify(result));
|
|
404
|
+
}
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
if (paginationStorage) {
|
|
408
|
+
await printSpooledAdminResult({ result, storage: paginationStorage });
|
|
386
409
|
return result;
|
|
387
410
|
}
|
|
388
411
|
printAdminResult({ operation, result });
|
|
@@ -2375,6 +2398,57 @@ function printAdminMonthlyMediaCosts(monthlyMediaCosts) {
|
|
|
2375
2398
|
);
|
|
2376
2399
|
}
|
|
2377
2400
|
|
|
2401
|
+
async function printSpooledAdminResult({ result, storage }) {
|
|
2402
|
+
const data = result?.data || {};
|
|
2403
|
+
const count = Number(storage.candidateCount || 0);
|
|
2404
|
+
if (storage.collectionKey === "subjects") {
|
|
2405
|
+
console.log(`${count} subject(s):`);
|
|
2406
|
+
await forEachPaginatedResultItem(result, async (subject) => {
|
|
2407
|
+
console.log(
|
|
2408
|
+
`#${subject.id} ${subject.title || "(untitled)"} — ${subject.author?.username || "unknown"} — effort ${subject.effortLevel ?? "unknown"}`,
|
|
2409
|
+
);
|
|
2410
|
+
console.log(` ${subject.url}`);
|
|
2411
|
+
});
|
|
2412
|
+
} else if (storage.collectionKey === "events") {
|
|
2413
|
+
console.log(`${count} audit event(s):`);
|
|
2414
|
+
await forEachPaginatedResultItem(result, async (event) => {
|
|
2415
|
+
const target =
|
|
2416
|
+
event.targetType && event.targetId
|
|
2417
|
+
? ` ${event.targetType}:${event.targetId}`
|
|
2418
|
+
: "";
|
|
2419
|
+
console.log(
|
|
2420
|
+
`#${event.id} run ${event.runId ?? "-"} ${event.action}${target} — ${event.result}${event.changed === true ? " (changed)" : event.changed === false ? " (no change)" : ""}`,
|
|
2421
|
+
);
|
|
2422
|
+
});
|
|
2423
|
+
} else if (storage.collectionKey === "comments") {
|
|
2424
|
+
console.log(`${count} comment(s) for ${data.subject?.url || "this target"}:`);
|
|
2425
|
+
await forEachPaginatedResultItem(result, async (comment) => {
|
|
2426
|
+
console.log(
|
|
2427
|
+
`#${comment.id} ${comment.author?.username || "unknown"}: ${comment.content || "(empty)"}`,
|
|
2428
|
+
);
|
|
2429
|
+
});
|
|
2430
|
+
} else if (storage.collectionKey === "items") {
|
|
2431
|
+
console.log(`${count} recommendation candidate(s):`);
|
|
2432
|
+
await forEachPaginatedResultItem(result, async (item) => {
|
|
2433
|
+
console.log(`#${item.contentId} ${item.contentType}`);
|
|
2434
|
+
if (item.url || item.subjectUrl) {
|
|
2435
|
+
console.log(` ${item.url || item.subjectUrl}`);
|
|
2436
|
+
}
|
|
2437
|
+
});
|
|
2438
|
+
} else if (storage.collectionKey === "builds") {
|
|
2439
|
+
console.log(`${count} Build candidate(s):`);
|
|
2440
|
+
await forEachPaginatedResultItem(result, async (build) => {
|
|
2441
|
+
console.log(`#${build.id} ${build.title || "(untitled)"}`);
|
|
2442
|
+
if (build.url) console.log(` ${build.url}`);
|
|
2443
|
+
});
|
|
2444
|
+
} else {
|
|
2445
|
+
console.log(
|
|
2446
|
+
`${count} ${storage.collectionKey} candidate(s) saved at ${storage.spoolPath}.`,
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
printPagination(data.pagination);
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2378
2452
|
function printAdminResult({ operation, result }) {
|
|
2379
2453
|
const data = result?.data || {};
|
|
2380
2454
|
if (Array.isArray(data.applications)) {
|
package/lib/auth.js
CHANGED
|
@@ -6,6 +6,9 @@ import { requestJson } from "./http.js";
|
|
|
6
6
|
import { sleep, trimTrailingSlash } from "./util.js";
|
|
7
7
|
|
|
8
8
|
export async function login(options) {
|
|
9
|
+
const report = options.loginProgressToStderr
|
|
10
|
+
? (...args) => console.error(...args)
|
|
11
|
+
: (...args) => console.log(...args);
|
|
9
12
|
const start = await requestJson({
|
|
10
13
|
method: "POST",
|
|
11
14
|
url: `${options.apiUrl}/cli/device/start`,
|
|
@@ -23,15 +26,15 @@ export async function login(options) {
|
|
|
23
26
|
});
|
|
24
27
|
|
|
25
28
|
const approvalUrl = start.verificationUriComplete || start.verificationUri;
|
|
26
|
-
|
|
29
|
+
report("Connect Lumine CLI to Twinkle.");
|
|
27
30
|
if (options.openBrowser && approvalUrl) {
|
|
28
|
-
|
|
31
|
+
report("Opening Twinkle in your browser...");
|
|
29
32
|
const opened = await openBrowser(approvalUrl);
|
|
30
|
-
if (!opened)
|
|
33
|
+
if (!opened) report("Could not open the browser automatically.");
|
|
31
34
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
report(`Approval link: ${approvalUrl}`);
|
|
36
|
+
report(`Code: ${start.userCode}`);
|
|
37
|
+
report("Leave this terminal open. Waiting for approval...");
|
|
35
38
|
|
|
36
39
|
const intervalMs = Math.max(Number(start.interval || 3), 1) * 1000;
|
|
37
40
|
const startedAt = Date.now();
|
|
@@ -52,10 +55,10 @@ export async function login(options) {
|
|
|
52
55
|
userId: tokenResponse.user?.id || null,
|
|
53
56
|
expiresAt: Date.now() + Number(tokenResponse.expiresIn || 0) * 1000,
|
|
54
57
|
});
|
|
55
|
-
|
|
58
|
+
report(
|
|
56
59
|
`Logged in${tokenResponse.user?.username ? ` as ${tokenResponse.user.username}` : ""}.`,
|
|
57
60
|
);
|
|
58
|
-
|
|
61
|
+
report("You can now run `lumine` to choose a project.");
|
|
59
62
|
return;
|
|
60
63
|
}
|
|
61
64
|
|
|
@@ -112,7 +115,10 @@ export async function ensureAuth(options) {
|
|
|
112
115
|
} catch (error) {
|
|
113
116
|
if (options.authToken || !isMissingLoginError(error)) throw error;
|
|
114
117
|
}
|
|
115
|
-
await login(
|
|
118
|
+
await login({
|
|
119
|
+
...options,
|
|
120
|
+
loginProgressToStderr: Boolean(options.json),
|
|
121
|
+
});
|
|
116
122
|
return await resolveAuth(options);
|
|
117
123
|
}
|
|
118
124
|
|
package/lib/commands.js
CHANGED
|
@@ -2907,7 +2907,7 @@ Options:
|
|
|
2907
2907
|
--since-run Recommendations since the previous completed run (default)
|
|
2908
2908
|
--include-legacy Explicitly include the full historical recommendation queue
|
|
2909
2909
|
--all Automatically read every page in the canonical snapshot
|
|
2910
|
-
--checkpoint <file> Persist
|
|
2910
|
+
--checkpoint <file> Persist bounded pagination metadata or batch progress
|
|
2911
2911
|
--resume Resume the exact request recorded by --checkpoint
|
|
2912
2912
|
--output <file> Save an admin result or newspaper claim as JSON
|
|
2913
2913
|
--output-dir <dir> Directory for managed Build review evidence
|
package/lib/sponsor.js
CHANGED
|
@@ -6,7 +6,7 @@ import { stdin as input, stdout as output } from "node:process";
|
|
|
6
6
|
|
|
7
7
|
import { agentCommand } from "./agent.js";
|
|
8
8
|
import { notifyBuildOwnerOfContribution } from "./api.js";
|
|
9
|
-
import { resolveAuth, writeAuthFile } from "./auth.js";
|
|
9
|
+
import { ensureAuth, resolveAuth, writeAuthFile } from "./auth.js";
|
|
10
10
|
import { readCompleteBuildForumSnapshot } from "./forum.js";
|
|
11
11
|
import { requestJson } from "./http.js";
|
|
12
12
|
import { sleep } from "./util.js";
|
|
@@ -24,7 +24,7 @@ export async function sponsorCommand(options, commandServices) {
|
|
|
24
24
|
.trim()
|
|
25
25
|
.toLowerCase();
|
|
26
26
|
if (area === "agreement") {
|
|
27
|
-
const auth = await
|
|
27
|
+
const auth = await ensureAuth(options);
|
|
28
28
|
const agreement = await sponsorRequest({
|
|
29
29
|
options,
|
|
30
30
|
auth,
|
|
@@ -50,7 +50,7 @@ export async function sponsorCommand(options, commandServices) {
|
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
52
|
if (area === "jobs") {
|
|
53
|
-
const auth = await
|
|
53
|
+
const auth = await ensureAuth(options);
|
|
54
54
|
const result = await sponsorRequest({
|
|
55
55
|
options,
|
|
56
56
|
auth,
|
|
@@ -67,7 +67,7 @@ export async function sponsorCommand(options, commandServices) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
async function applyToSponsor(options) {
|
|
70
|
-
const auth = await
|
|
70
|
+
const auth = await ensureAuth(options);
|
|
71
71
|
const agreement = await sponsorRequest({ options, auth, path: "/agreement" });
|
|
72
72
|
if (!options.json) {
|
|
73
73
|
printSponsorAgreement(agreement, { ...options, json: false });
|
|
@@ -135,7 +135,7 @@ async function applyToSponsor(options) {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
async function withdrawSponsorApplication(options) {
|
|
138
|
-
const auth = await
|
|
138
|
+
const auth = await ensureAuth(options);
|
|
139
139
|
if (!options.assumeYes && input.isTTY && output.isTTY) {
|
|
140
140
|
const rl = readline.createInterface({ input, output });
|
|
141
141
|
try {
|
|
@@ -162,7 +162,7 @@ async function withdrawSponsorApplication(options) {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
async function updateSponsorCapacity(options) {
|
|
165
|
-
const auth = await
|
|
165
|
+
const auth = await ensureAuth(options);
|
|
166
166
|
const status = await sponsorRequest({ options, auth, path: "/status" });
|
|
167
167
|
if (!status.sponsor) {
|
|
168
168
|
throw new Error("An approved sponsor account is required first.");
|
|
@@ -216,8 +216,8 @@ async function sponsorDutyCommand(options, args, commandServices) {
|
|
|
216
216
|
return;
|
|
217
217
|
}
|
|
218
218
|
const persona = normalizePersona(args[1] || options.sponsorPersona);
|
|
219
|
-
const auth = await resolveAuth(options);
|
|
220
219
|
if (["pause", "resume", "stop"].includes(action)) {
|
|
220
|
+
const auth = await ensureAuth(options);
|
|
221
221
|
const state =
|
|
222
222
|
action === "resume" ? "active" : action === "stop" ? "stopped" : "paused";
|
|
223
223
|
const result = await sponsorRequest({
|
|
@@ -242,6 +242,7 @@ async function sponsorDutyCommand(options, args, commandServices) {
|
|
|
242
242
|
"--service-tier is currently supported only by the Codex duty adapter.",
|
|
243
243
|
);
|
|
244
244
|
}
|
|
245
|
+
const auth = await ensureAuth(options);
|
|
245
246
|
const started = await sponsorRequest({
|
|
246
247
|
options,
|
|
247
248
|
auth,
|
|
@@ -1134,7 +1135,7 @@ async function sponsorRequest({
|
|
|
1134
1135
|
}
|
|
1135
1136
|
|
|
1136
1137
|
async function printSponsorStatus(options) {
|
|
1137
|
-
const auth = await
|
|
1138
|
+
const auth = await ensureAuth(options);
|
|
1138
1139
|
const status = await sponsorRequest({ options, auth, path: "/status" });
|
|
1139
1140
|
if (options.json) {
|
|
1140
1141
|
console.log(JSON.stringify(status, null, 2));
|
package/package.json
CHANGED
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -881,12 +881,15 @@ feed-ID high-water mark and the server timestamp, then traverse the indexed
|
|
|
881
881
|
row moved forward when it was reshared. Explicit legacy scans retain the
|
|
882
882
|
descending primary-key walk. A page
|
|
883
883
|
can be empty while `hasMore` remains true; continue until `exhausted`. `--all`
|
|
884
|
-
does that automatically
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
884
|
+
does that automatically, fsyncs each confirmed page to a private NDJSON
|
|
885
|
+
candidate spool, and writes only bounded cursor, boundary, count, byte-length,
|
|
886
|
+
and digest metadata to the private checkpoint. `--resume` continues only when
|
|
887
|
+
the checkpoint belongs to the same API, run, and exact request and its confirmed
|
|
888
|
+
spool prefix still matches those metadata; an unconfirmed fsynced tail is
|
|
889
|
+
discarded before the next request. The final JSON collection is streamed from
|
|
890
|
+
the spool and can be copied to a separate `--output` file, while `--checkpoint`
|
|
891
|
+
remains resumable operational state. Subject `--after` is inclusive, and every
|
|
892
|
+
opaque cursor is bound to its original filters.
|
|
890
893
|
|
|
891
894
|
For `--all --json`, stdout remains exactly one JSON value. Scan-start, first-
|
|
892
895
|
page, every-tenth-page, and exhaustion progress is written to **stderr** with
|