@stage5/lumine 0.2.40 → 0.2.41

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.
@@ -0,0 +1,419 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { writeAdminJsonFile, readAdminJsonFile } from "./admin-news.js";
7
+ import { requestJson } from "./http.js";
8
+
9
+ const MAX_BATCH_TARGET_FILE_BYTES = 2 * 1024 * 1024;
10
+ const MAX_BATCH_TARGETS = 20_000;
11
+ const MAX_AUTOMATIC_PAGES = 100_000;
12
+ const MAX_ADMIN_CHECKPOINT_BYTES = 64 * 1024 * 1024;
13
+ const ADMIN_PAGINATION_SCHEMA_VERSION = 2;
14
+ const ADMIN_BATCH_SCHEMA_VERSION = 2;
15
+
16
+ function validationError(message) {
17
+ const error = new Error(message);
18
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
19
+ return error;
20
+ }
21
+
22
+ function fingerprint(value) {
23
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
24
+ }
25
+
26
+ function normalizeBoundary(value, label) {
27
+ if (value === null || value === undefined || value === "") return null;
28
+ const normalized = Number(value);
29
+ if (!Number.isSafeInteger(normalized) || normalized < 0) {
30
+ throw validationError(`The API returned an invalid ${label} boundary.`);
31
+ }
32
+ return normalized;
33
+ }
34
+
35
+ function normalizeScannedCount(value, fallback) {
36
+ const candidate = value === undefined ? fallback : Number(value);
37
+ if (!Number.isSafeInteger(candidate) || candidate < 0) {
38
+ throw validationError("The API returned an invalid scannedCount value.");
39
+ }
40
+ return candidate;
41
+ }
42
+
43
+ function assertStableBoundary(label, expected, actual) {
44
+ if (expected === actual) return;
45
+ throw validationError(
46
+ `The API changed ${label} while paging one canonical snapshot. Start a fresh scan.`,
47
+ );
48
+ }
49
+
50
+ function pathWithCursor(requestPath, cursor) {
51
+ const url = new URL(requestPath, "https://lumine.invalid");
52
+ if (cursor) url.searchParams.set("cursor", cursor);
53
+ else url.searchParams.delete("cursor");
54
+ return `${url.pathname}${url.search}`;
55
+ }
56
+
57
+ function defaultCheckpointPath({ runId, operationName }) {
58
+ const safeName = String(operationName).replace(/[^a-z0-9_.-]+/gi, "-");
59
+ return path.join(os.tmpdir(), `lumine-admin-run-${runId}-${safeName}.json`);
60
+ }
61
+
62
+ function aggregatePageResult({ operation, pages, checkpointPath, state }) {
63
+ const last = pages[pages.length - 1] || { ok: true, status: "success", data: {} };
64
+ return {
65
+ ...last,
66
+ data: {
67
+ ...(last.data || {}),
68
+ [operation.pagination.collectionKey]: state.items,
69
+ pagination: {
70
+ ...(last.data?.pagination || {}),
71
+ nextCursor: state.nextCursor,
72
+ hasMore: !state.exhausted,
73
+ exhausted: state.exhausted,
74
+ after: state.after,
75
+ snapshotTimeStamp: state.snapshotTimeStamp,
76
+ },
77
+ scan: {
78
+ pages: state.pages,
79
+ scannedCount: state.scannedCount,
80
+ candidateCount: state.items.length,
81
+ checkpointPath,
82
+ resumed: state.resumed,
83
+ },
84
+ },
85
+ };
86
+ }
87
+
88
+ export async function runAutomaticPagination({
89
+ options,
90
+ operation,
91
+ runId,
92
+ fetchPage,
93
+ transformPage,
94
+ recordCoverage,
95
+ }) {
96
+ if (!operation.pagination) {
97
+ throw validationError("--all is supported only by paginated admin listings.");
98
+ }
99
+ const checkpointPath = path.resolve(
100
+ options.adminCheckpoint ||
101
+ defaultCheckpointPath({ runId, operationName: operation.name }),
102
+ );
103
+ const operationFingerprint = fingerprint({
104
+ workflowSchemaVersion: ADMIN_PAGINATION_SCHEMA_VERSION,
105
+ runId,
106
+ apiUrl: String(options.apiUrl || "").replace(/\/$/, ""),
107
+ name: operation.name,
108
+ path: pathWithCursor(operation.path, ""),
109
+ pagination: {
110
+ collectionKey: operation.pagination.collectionKey,
111
+ coverageQueue: operation.pagination.coverageQueue || null,
112
+ coverageMode: operation.pagination.coverageMode || null,
113
+ after: operation.pagination.after ?? null,
114
+ filters: operation.pagination.filters || {},
115
+ },
116
+ });
117
+ let state = {
118
+ schemaVersion: ADMIN_PAGINATION_SCHEMA_VERSION,
119
+ kind: "admin-pagination",
120
+ operationFingerprint,
121
+ runId,
122
+ operationName: operation.name,
123
+ nextCursor: "",
124
+ exhausted: false,
125
+ pages: 0,
126
+ scannedCount: 0,
127
+ snapshotMaxId: null,
128
+ snapshotTimeStamp: null,
129
+ after: operation.pagination.after ?? null,
130
+ boundariesConfirmed: false,
131
+ items: [],
132
+ resumed: false,
133
+ updatedAt: new Date().toISOString(),
134
+ };
135
+ if (options.adminResume) {
136
+ const saved = readAdminJsonFile(
137
+ checkpointPath,
138
+ "the pagination checkpoint",
139
+ { maxBytes: MAX_ADMIN_CHECKPOINT_BYTES },
140
+ );
141
+ if (
142
+ saved?.schemaVersion !== ADMIN_PAGINATION_SCHEMA_VERSION ||
143
+ saved?.kind !== "admin-pagination" ||
144
+ saved?.operationFingerprint !== operationFingerprint ||
145
+ Number(saved?.runId) !== runId ||
146
+ !Array.isArray(saved?.items) ||
147
+ saved?.boundariesConfirmed !== true
148
+ ) {
149
+ throw validationError(
150
+ "The checkpoint does not belong to this run and exact listing request.",
151
+ );
152
+ }
153
+ state = { ...state, ...saved, resumed: true };
154
+ }
155
+ const pages = [];
156
+ while (!state.exhausted) {
157
+ if (state.pages >= MAX_AUTOMATIC_PAGES) {
158
+ throw validationError(
159
+ `Automatic pagination stopped at the ${MAX_AUTOMATIC_PAGES}-page safety ceiling. Resume after narrowing the request.`,
160
+ );
161
+ }
162
+ const requestPath = pathWithCursor(operation.path, state.nextCursor);
163
+ const raw = await fetchPage(requestPath);
164
+ const page = transformPage(raw);
165
+ pages.push(page);
166
+ const data = page?.data || {};
167
+ const pagination = data.pagination;
168
+ if (!pagination || typeof pagination !== "object" || Array.isArray(pagination)) {
169
+ throw validationError("The API response is missing canonical pagination metadata.");
170
+ }
171
+ const items = Array.isArray(data[operation.pagination.collectionKey])
172
+ ? data[operation.pagination.collectionKey]
173
+ : [];
174
+ if (typeof pagination.exhausted !== "boolean") {
175
+ throw validationError(
176
+ "The API must explicitly confirm whether the canonical snapshot is exhausted.",
177
+ );
178
+ }
179
+ const nextCursor = String(pagination.nextCursor || "");
180
+ if (nextCursor && nextCursor === state.nextCursor) {
181
+ throw validationError("The API returned the same pagination cursor twice.");
182
+ }
183
+ if (!pagination.exhausted && !nextCursor) {
184
+ throw validationError(
185
+ "The API reported more canonical pages without returning the next cursor.",
186
+ );
187
+ }
188
+ if (pagination.exhausted && nextCursor) {
189
+ throw validationError(
190
+ "The API returned a next cursor after declaring the canonical snapshot exhausted.",
191
+ );
192
+ }
193
+ const pageSnapshotMaxId = normalizeBoundary(
194
+ pagination.snapshotMaxId,
195
+ "snapshotMaxId",
196
+ );
197
+ const pageSnapshotTimeStamp = normalizeBoundary(
198
+ pagination.snapshotTimeStamp,
199
+ "snapshotTimeStamp",
200
+ );
201
+ const pageAfter = normalizeBoundary(pagination.after, "after");
202
+ if (state.boundariesConfirmed) {
203
+ assertStableBoundary("snapshotMaxId", state.snapshotMaxId, pageSnapshotMaxId);
204
+ assertStableBoundary(
205
+ "snapshotTimeStamp",
206
+ state.snapshotTimeStamp,
207
+ pageSnapshotTimeStamp,
208
+ );
209
+ assertStableBoundary("after", state.after, pageAfter);
210
+ } else {
211
+ const requestedAfter = normalizeBoundary(operation.pagination.after, "after");
212
+ if (requestedAfter !== null) {
213
+ assertStableBoundary("after", requestedAfter, pageAfter);
214
+ }
215
+ state.snapshotMaxId = pageSnapshotMaxId;
216
+ state.snapshotTimeStamp = pageSnapshotTimeStamp;
217
+ state.after = requestedAfter ?? pageAfter;
218
+ state.boundariesConfirmed = true;
219
+ }
220
+ state.items.push(...items);
221
+ state.pages += 1;
222
+ state.scannedCount += normalizeScannedCount(
223
+ pagination.scannedCount,
224
+ items.length,
225
+ );
226
+ state.nextCursor = nextCursor;
227
+ state.exhausted = pagination.exhausted;
228
+ state.updatedAt = new Date().toISOString();
229
+ writeAdminJsonFile(checkpointPath, state, {
230
+ privateFile: true,
231
+ maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
232
+ });
233
+ }
234
+ const result = aggregatePageResult({
235
+ operation,
236
+ pages,
237
+ checkpointPath,
238
+ state,
239
+ });
240
+ if (recordCoverage && operation.pagination.coverageQueue) {
241
+ await recordCoverage({
242
+ queue: operation.pagination.coverageQueue,
243
+ mode: operation.pagination.coverageMode || "all",
244
+ after: state.after,
245
+ pages: state.pages,
246
+ scannedCount: state.scannedCount,
247
+ candidateCount: state.items.length,
248
+ snapshotMaxId: state.snapshotMaxId,
249
+ snapshotTimeStamp: state.snapshotTimeStamp,
250
+ exhausted: state.exhausted,
251
+ filters: operation.pagination.filters || {},
252
+ });
253
+ }
254
+ if (options.adminOutput) {
255
+ result.data.scan.outputPath = writeAdminJsonFile(options.adminOutput, result, {
256
+ privateFile: true,
257
+ maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
258
+ });
259
+ }
260
+ return result;
261
+ }
262
+
263
+ export function readBatchSkipTargets({ filePath, parseTarget, defaultReason }) {
264
+ const normalizedPath = String(filePath || "").trim();
265
+ if (!normalizedPath) {
266
+ throw validationError("Pass batch skip targets with --target-file <file>.");
267
+ }
268
+ let contents;
269
+ try {
270
+ contents = readFileSync(normalizedPath, "utf8");
271
+ } catch {
272
+ throw validationError(`Could not read ${normalizedPath}.`);
273
+ }
274
+ if (Buffer.byteLength(contents, "utf8") > MAX_BATCH_TARGET_FILE_BYTES) {
275
+ throw validationError("The batch target file must be under 2 MB.");
276
+ }
277
+ let values;
278
+ try {
279
+ const parsed = JSON.parse(contents);
280
+ values = Array.isArray(parsed) ? parsed : parsed?.targets;
281
+ } catch {
282
+ values = contents
283
+ .split(/\r?\n/)
284
+ .map((line) => line.trim())
285
+ .filter((line) => line && !line.startsWith("#"));
286
+ }
287
+ if (!Array.isArray(values) || values.length === 0) {
288
+ throw validationError("The batch target file contains no targets.");
289
+ }
290
+ if (values.length > MAX_BATCH_TARGETS) {
291
+ throw validationError(`A batch can contain at most ${MAX_BATCH_TARGETS} targets.`);
292
+ }
293
+ const deduped = new Map();
294
+ for (const value of values) {
295
+ const rawTarget =
296
+ typeof value === "string" ? value : value?.target || value?.url || value?.id;
297
+ const parsed = parseTarget({
298
+ target: rawTarget,
299
+ explicitType: typeof value === "object" ? value?.type || "" : "",
300
+ });
301
+ if (parsed.type === "subject") {
302
+ throw validationError(
303
+ "Batch skips accept comment, aiStory, or dailyReflection targets, not subjects.",
304
+ );
305
+ }
306
+ const key = `${parsed.type}:${parsed.id}`;
307
+ if (!deduped.has(key)) {
308
+ deduped.set(key, {
309
+ key,
310
+ type: parsed.type,
311
+ id: parsed.id,
312
+ reason:
313
+ typeof value === "object" && value?.reason
314
+ ? String(value.reason)
315
+ : String(defaultReason || "") || undefined,
316
+ });
317
+ }
318
+ }
319
+ return [...deduped.values()];
320
+ }
321
+
322
+ export async function runBatchSkips({
323
+ options,
324
+ authToken,
325
+ runId,
326
+ parseTarget,
327
+ }) {
328
+ const targets = readBatchSkipTargets({
329
+ filePath: options.adminTargetFile,
330
+ parseTarget,
331
+ defaultReason: options.adminReason,
332
+ });
333
+ const batchFingerprint = fingerprint({
334
+ workflowSchemaVersion: ADMIN_BATCH_SCHEMA_VERSION,
335
+ apiUrl: String(options.apiUrl || "").replace(/\/$/, ""),
336
+ targets,
337
+ });
338
+ const checkpointPath = path.resolve(
339
+ options.adminCheckpoint ||
340
+ defaultCheckpointPath({ runId, operationName: "post.skip-batch" }),
341
+ );
342
+ let state = {
343
+ schemaVersion: ADMIN_BATCH_SCHEMA_VERSION,
344
+ kind: "admin-skip-batch",
345
+ runId,
346
+ batchFingerprint,
347
+ targetCount: targets.length,
348
+ completed: {},
349
+ updatedAt: new Date().toISOString(),
350
+ };
351
+ if (options.adminResume) {
352
+ const saved = readAdminJsonFile(checkpointPath, "the batch checkpoint", {
353
+ maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
354
+ });
355
+ if (
356
+ saved?.schemaVersion !== ADMIN_BATCH_SCHEMA_VERSION ||
357
+ saved?.kind !== "admin-skip-batch" ||
358
+ Number(saved.runId) !== runId ||
359
+ saved.batchFingerprint !== batchFingerprint ||
360
+ !saved.completed ||
361
+ typeof saved.completed !== "object"
362
+ ) {
363
+ throw validationError("The checkpoint does not match this exact skip batch.");
364
+ }
365
+ state = saved;
366
+ } else {
367
+ writeAdminJsonFile(checkpointPath, state, {
368
+ privateFile: true,
369
+ maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
370
+ });
371
+ }
372
+ for (const target of targets) {
373
+ if (state.completed[target.key]) continue;
374
+ const result = await requestJson({
375
+ method: "POST",
376
+ url: `${options.apiUrl}/cli/admin/skips/${target.type}/${target.id}`,
377
+ authToken,
378
+ body: { reason: target.reason },
379
+ headers: {
380
+ "x-lumine-admin-run-id": String(runId),
381
+ "x-lumine-idempotency-key": `cli:skip-batch:${runId}:${batchFingerprint.slice(0, 12)}:${fingerprint(target.key).slice(0, 24)}`,
382
+ },
383
+ timeoutMs: options.timeoutMs,
384
+ });
385
+ state.completed[target.key] = {
386
+ status: result?.status || "success",
387
+ changed: result?.changed === true,
388
+ completedAt: new Date().toISOString(),
389
+ };
390
+ state.updatedAt = new Date().toISOString();
391
+ writeAdminJsonFile(checkpointPath, state, {
392
+ privateFile: true,
393
+ maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
394
+ });
395
+ }
396
+ const result = {
397
+ ok: true,
398
+ status: "success",
399
+ changed: Object.values(state.completed).some((entry) => entry.changed),
400
+ data: {
401
+ batch: {
402
+ targetCount: targets.length,
403
+ completedCount: Object.keys(state.completed).length,
404
+ changedCount: Object.values(state.completed).filter((entry) => entry.changed)
405
+ .length,
406
+ checkpointPath,
407
+ resumed: options.adminResume === true,
408
+ targets: state.completed,
409
+ },
410
+ },
411
+ };
412
+ if (options.adminOutput) {
413
+ result.data.batch.outputPath = writeAdminJsonFile(options.adminOutput, result, {
414
+ privateFile: true,
415
+ maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
416
+ });
417
+ }
418
+ return result;
419
+ }