@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 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, saves a checkpoint after every canonical page,
387
- and records completed queue coverage in the run audit; `--resume` continues
388
- the exact same request. With `--all --json`, bounded progress goes to stderr so
389
- stdout remains one pipe-safe JSON value. Recommendation scans default to the previous completed
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
@@ -1,7 +1,22 @@
1
- import { createHash } from "node:crypto";
2
- import { readFileSync } from "node:fs";
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 MAX_ADMIN_CHECKPOINT_BYTES = 64 * 1024 * 1024;
13
- const ADMIN_PAGINATION_SCHEMA_VERSION = 2;
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 aggregatePageResult({ operation, pages, checkpointPath, state }) {
63
- const last = pages[pages.length - 1] || { ok: true, status: "success", data: {} };
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
- ...(last.data || {}),
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.items.length,
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 operationFingerprint = fingerprint({
105
- workflowSchemaVersion: ADMIN_PAGINATION_SCHEMA_VERSION,
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
- items: [],
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: MAX_ADMIN_CHECKPOINT_BYTES },
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
- saved?.schemaVersion !== ADMIN_PAGINATION_SCHEMA_VERSION ||
144
- saved?.kind !== "admin-pagination" ||
145
- saved?.operationFingerprint !== operationFingerprint ||
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 does not belong to this run and exact listing request.",
703
+ "The pagination checkpoint has inconsistent exhaustion metadata.",
152
704
  );
153
705
  }
154
- state = { ...state, ...saved, resumed: true };
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
- const pages = [];
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. Resume after narrowing the request.`,
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
- pages.push(page);
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
- state.items.push(...items);
228
- state.pages += 1;
229
- state.scannedCount += normalizeScannedCount(
230
- pagination.scannedCount,
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.length === 1 || state.pages % 10 === 0 || state.exhausted)
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.items.length} candidate(s)${state.exhausted ? "; canonical snapshot exhausted." : "; continuing."}`,
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
- pages,
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.items.length,
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 (options.adminOutput) {
270
- result.data.scan.outputPath = writeAdminJsonFile(options.adminOutput, result, {
271
- privateFile: true,
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: MAX_ADMIN_CHECKPOINT_BYTES,
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: MAX_ADMIN_CHECKPOINT_BYTES,
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: MAX_ADMIN_CHECKPOINT_BYTES,
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: MAX_ADMIN_CHECKPOINT_BYTES,
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 { runAutomaticPagination, runBatchSkips } from "./admin-workflows.js";
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
- console.log(JSON.stringify(result));
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
- console.log("Connect Lumine CLI to Twinkle.");
29
+ report("Connect Lumine CLI to Twinkle.");
27
30
  if (options.openBrowser && approvalUrl) {
28
- console.log("Opening Twinkle in your browser...");
31
+ report("Opening Twinkle in your browser...");
29
32
  const opened = await openBrowser(approvalUrl);
30
- if (!opened) console.log("Could not open the browser automatically.");
33
+ if (!opened) report("Could not open the browser automatically.");
31
34
  }
32
- console.log(`Approval link: ${approvalUrl}`);
33
- console.log(`Code: ${start.userCode}`);
34
- console.log("Leave this terminal open. Waiting for approval...");
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
- console.log(
58
+ report(
56
59
  `Logged in${tokenResponse.user?.username ? ` as ${tokenResponse.user.username}` : ""}.`,
57
60
  );
58
- console.log("You can now run `lumine` to choose a project.");
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(options);
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 automatic pagination or batch progress
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 resolveAuth(options);
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 resolveAuth(options);
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 resolveAuth(options);
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 resolveAuth(options);
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 resolveAuth(options);
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 resolveAuth(options);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.54",
3
+ "version": "0.2.55",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 and writes a private checkpoint after every
885
- server-confirmed page; `--resume` continues only when the checkpoint belongs to
886
- the same API, run, and exact request. The final result can be copied to
887
- `--output`, while `--checkpoint` is resumable operational state. Subject
888
- `--after` is inclusive, and every opaque cursor is bound to its original
889
- filters.
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