@stage5/lumine 0.2.68 → 0.2.69

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,185 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
6
+ import {
7
+ acquireCheckpointLock,
8
+ releaseCheckpointLock,
9
+ runAutomaticPagination,
10
+ combinePaginatedResults,
11
+ writePaginatedResultFile,
12
+ } from "./admin-workflows.js";
13
+
14
+ // API read bound, deliberately unrelated to the delegated addition policy.
15
+ export const FEATURED_HISTORY_BATCH_SIZE = 100;
16
+
17
+ function invalid(message) {
18
+ const error = new Error(message);
19
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
20
+ return error;
21
+ }
22
+
23
+ export async function runBatchedFeaturedHistory(args) {
24
+ const { options, operation, runId } = args;
25
+ if (options.adminCursor) {
26
+ throw invalid("Use --resume with --all, not a single-batch --cursor.");
27
+ }
28
+ const subjectIds = operation.pagination.filters.subjectIds;
29
+ const fingerprint = createHash("sha256")
30
+ .update(
31
+ JSON.stringify({
32
+ version: 1,
33
+ runId,
34
+ apiUrl: options.apiUrl,
35
+ path: operation.path,
36
+ }),
37
+ )
38
+ .digest("hex");
39
+ const checkpointPath = path.resolve(
40
+ options.adminCheckpoint ||
41
+ path.join(
42
+ os.tmpdir(),
43
+ `lumine-admin-run-${runId}-featured-history-${fingerprint}.json`,
44
+ ),
45
+ );
46
+ const outputPath = options.adminOutput
47
+ ? path.resolve(options.adminOutput)
48
+ : null;
49
+ if (
50
+ outputPath === checkpointPath ||
51
+ outputPath?.startsWith(`${checkpointPath}.`)
52
+ ) {
53
+ throw invalid(
54
+ "History output must not overwrite its checkpoint or batch files.",
55
+ );
56
+ }
57
+ const lock = acquireCheckpointLock(checkpointPath, fingerprint);
58
+ try {
59
+ let generation;
60
+ if (options.adminResume) {
61
+ const saved = readAdminJsonFile(
62
+ checkpointPath,
63
+ "the Featured history batch checkpoint",
64
+ );
65
+ if (
66
+ saved.kind !== "featured-history-batches" ||
67
+ saved.fingerprint !== fingerprint ||
68
+ !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(
69
+ saved.generation || "",
70
+ )
71
+ ) {
72
+ throw invalid(
73
+ "The checkpoint does not belong to this run and exact history request.",
74
+ );
75
+ }
76
+ generation = saved.generation;
77
+ } else {
78
+ generation = randomUUID();
79
+ writeAdminJsonFile(
80
+ checkpointPath,
81
+ { kind: "featured-history-batches", fingerprint, generation },
82
+ { privateFile: true },
83
+ );
84
+ }
85
+ const results = [];
86
+ for (
87
+ let offset = 0;
88
+ offset < subjectIds.length;
89
+ offset += FEATURED_HISTORY_BATCH_SIZE
90
+ ) {
91
+ const ids = subjectIds.slice(
92
+ offset,
93
+ offset + FEATURED_HISTORY_BATCH_SIZE,
94
+ );
95
+ const batchIndex = offset / FEATURED_HISTORY_BATCH_SIZE;
96
+ const batchCheckpoint = `${checkpointPath}.${generation}.batch-${batchIndex}.json`;
97
+ const url = new URL(operation.path, "https://lumine.invalid");
98
+ url.searchParams.set("subjectIds", ids.join(","));
99
+ const result = await runAutomaticPagination({
100
+ ...args,
101
+ options: {
102
+ ...options,
103
+ adminOutput: undefined,
104
+ adminCheckpoint: batchCheckpoint,
105
+ adminResume: !!options.adminResume && existsSync(batchCheckpoint),
106
+ },
107
+ operation: {
108
+ ...operation,
109
+ path: `${url.pathname}${url.search}`,
110
+ pagination: { ...operation.pagination, filters: { subjectIds: ids } },
111
+ },
112
+ });
113
+ if (
114
+ JSON.stringify(result.data.subjects?.map((subject) => subject.id)) !==
115
+ JSON.stringify(ids)
116
+ ) {
117
+ throw invalid(
118
+ "The API did not return a canonical summary for every requested subject.",
119
+ );
120
+ }
121
+ results.push(result);
122
+ }
123
+ const firstCoverage = results[0].data.coverage;
124
+ const uniformCoverage = results.every(
125
+ (result) =>
126
+ result.data.coverage?.complete === firstCoverage.complete &&
127
+ result.data.coverage?.startedAt === firstCoverage.startedAt,
128
+ );
129
+ const result = combinePaginatedResults(
130
+ {
131
+ ok: true,
132
+ status: "success",
133
+ data: {
134
+ coverage: uniformCoverage
135
+ ? firstCoverage
136
+ : { complete: false, startedAt: null },
137
+ subjects: results.flatMap((result) => result.data.subjects),
138
+ pagination: {
139
+ nextCursor: null,
140
+ hasMore: false,
141
+ exhausted: true,
142
+ snapshotMaxId: null,
143
+ snapshotTimeStamp: null,
144
+ after: null,
145
+ snapshotScope: "per-batch",
146
+ eventOrder: "subject-batch-then-id-descending",
147
+ },
148
+ scan: {
149
+ checkpointPath,
150
+ resumed: !!options.adminResume,
151
+ pages: results.reduce(
152
+ (sum, result) => sum + result.data.scan.pages,
153
+ 0,
154
+ ),
155
+ scannedCount: results.reduce(
156
+ (sum, result) => sum + result.data.scan.scannedCount,
157
+ 0,
158
+ ),
159
+ candidateCount: results.reduce(
160
+ (sum, result) => sum + result.data.scan.candidateCount,
161
+ 0,
162
+ ),
163
+ batches: results.map((result, index) => ({
164
+ subjectIds: subjectIds.slice(
165
+ index * FEATURED_HISTORY_BATCH_SIZE,
166
+ (index + 1) * FEATURED_HISTORY_BATCH_SIZE,
167
+ ),
168
+ coverage: result.data.coverage,
169
+ pagination: result.data.pagination,
170
+ scan: result.data.scan,
171
+ })),
172
+ },
173
+ },
174
+ },
175
+ results,
176
+ );
177
+ if (outputPath) {
178
+ result.data.scan.outputPath = outputPath;
179
+ await writePaginatedResultFile(outputPath, result);
180
+ }
181
+ return result;
182
+ } finally {
183
+ releaseCheckpointLock(lock);
184
+ }
185
+ }
@@ -141,7 +141,7 @@ function startIntentPath(options) {
141
141
  );
142
142
  }
143
143
 
144
- function readStartIntent(intentPath, apiUrl) {
144
+ function readStartIntent(intentPath, apiUrl, host = "primary") {
145
145
  if (!existsSync(intentPath)) return null;
146
146
  const intent = readAdminJsonFile(
147
147
  intentPath,
@@ -156,6 +156,11 @@ function readStartIntent(intentPath, apiUrl) {
156
156
  ) {
157
157
  return null;
158
158
  }
159
+ if ((intent.host || "primary") !== host) {
160
+ throw validationError(
161
+ `A ${intent.host || "primary"} runtime-log start has an unresolved outcome. Replay that host's start before choosing ${host}; its request key was preserved.`,
162
+ );
163
+ }
159
164
  return intent;
160
165
  }
161
166
 
@@ -185,6 +190,7 @@ function createSession({ options, apiUrl, review }) {
185
190
  kind: "lumine-admin-runtime-log-review",
186
191
  apiUrl,
187
192
  reviewId: review.id,
193
+ ...(review.ownerHostId ? { ownerHostId: review.ownerHostId } : {}),
188
194
  leaseToken: review.leaseToken,
189
195
  status: review.status,
190
196
  outputDirectory,
@@ -581,10 +587,14 @@ export async function runAdminRuntimeLogWorkflow({
581
587
  }) {
582
588
  const apiUrl = normalizeApiUrl(options.apiUrl);
583
589
  if (operation.runtimeLogAction === "start") {
590
+ const host = operation.runtimeLogHost || "primary";
591
+ if (!["primary", "target"].includes(host)) {
592
+ throw validationError("Invalid runtime-log host.");
593
+ }
584
594
  const intentPath = startIntentPath(options);
585
595
  const intent = options.idempotencyKey
586
596
  ? null
587
- : readStartIntent(intentPath, apiUrl);
597
+ : readStartIntent(intentPath, apiUrl, host);
588
598
  const startRequestId = intent?.requestId || requestId;
589
599
  if (!intent) {
590
600
  writeAdminJsonFile(
@@ -592,6 +602,7 @@ export async function runAdminRuntimeLogWorkflow({
592
602
  {
593
603
  kind: "lumine-admin-runtime-log-review-start",
594
604
  apiUrl,
605
+ host,
595
606
  requestId: startRequestId,
596
607
  createdAt: new Date().toISOString(),
597
608
  },
@@ -602,7 +613,7 @@ export async function runAdminRuntimeLogWorkflow({
602
613
  try {
603
614
  result = await requestJson({
604
615
  method: "POST",
605
- url: `${apiUrl}/cli/admin/runtime-logs/reviews`,
616
+ url: `${apiUrl}/cli/admin/runtime-logs${operation.runtimeLogHost ? `/hosts/${host}` : ""}/reviews`,
606
617
  authToken,
607
618
  body: {},
608
619
  headers: requestHeaders({ requestId: startRequestId }),
@@ -631,6 +642,15 @@ export async function runAdminRuntimeLogWorkflow({
631
642
  throw error;
632
643
  }
633
644
  const review = validateReview(result?.data?.review);
645
+ if (
646
+ operation.runtimeLogHost &&
647
+ (!/^i-[a-f0-9]{17}$/.test(review.ownerHostId || "") ||
648
+ review.ownerHostRole !== host)
649
+ ) {
650
+ throw validationError(
651
+ "The API did not confirm a host-owned review. Preserve the start intent and recover it; do not clear logs.",
652
+ );
653
+ }
634
654
  const session = createSession({ options, apiUrl, review });
635
655
  // The session now holds the lease; a later rerun must start fresh.
636
656
  clearStartIntent(intentPath);
@@ -282,6 +282,15 @@ function operationFingerprintValue({
282
282
  apiUrl: String(options.apiUrl || "").replace(/\/$/, ""),
283
283
  name: operation.name,
284
284
  path: pathWithCursor(operation.path, ""),
285
+ ...(operation.pagination.summaryKeys
286
+ ? { summaryKeys: operation.pagination.summaryKeys }
287
+ : {}),
288
+ // A completed pre-upgrade scan can still contain the old 30-day cap.
289
+ // Reject that checkpoint too: it would otherwise skip fetching entirely
290
+ // and record truncated coverage as a successful since-run scan.
291
+ ...(operation.pagination.coverageMode === "since-run"
292
+ ? { discoveryWindowContract: "stored-run-start-v1" }
293
+ : {}),
285
294
  ...(operation.name === "builds.candidates"
286
295
  ? {
287
296
  resultTransform: {
@@ -637,6 +646,16 @@ export async function forEachPaginatedResultItem(result, visit) {
637
646
  "The result does not contain spooled pagination data.",
638
647
  );
639
648
  }
649
+ if (storage.parts) {
650
+ let index = 0;
651
+ for (const part of storage.parts) {
652
+ await forEachPaginatedResultItem(
653
+ attachPaginationStorage({}, part),
654
+ async (item) => visit(item, index++),
655
+ );
656
+ }
657
+ return;
658
+ }
640
659
  verifyCandidateSpool({ ...storage, discardUnconfirmedTail: false });
641
660
  const input = createReadStream(storage.spoolPath, { encoding: "utf8" });
642
661
  const observedHash = createHash("sha256");
@@ -706,7 +725,7 @@ export async function writePaginatedResultJson({ result, write }) {
706
725
  await write("]}}\n");
707
726
  }
708
727
 
709
- async function writePaginatedResultFile(filePath, result) {
728
+ export async function writePaginatedResultFile(filePath, result) {
710
729
  const resolved = path.resolve(String(filePath || "").trim());
711
730
  if (!String(filePath || "").trim()) {
712
731
  throw validationError("An output file path is required.");
@@ -743,9 +762,26 @@ async function writePaginatedResultFile(filePath, result) {
743
762
  return resolved;
744
763
  }
745
764
 
765
+ // Keep large history event sets on disk when combining independently bounded
766
+ // subject batches. Each part retains its own verified spool and snapshot.
767
+ export function combinePaginatedResults(result, parts) {
768
+ const storages = parts.map(getPaginatedResultStorage);
769
+ if (
770
+ !storages.length ||
771
+ storages.some((entry) => !entry || entry.collectionKey !== storages[0].collectionKey)
772
+ ) {
773
+ throw validationError("Cannot combine incompatible paginated results.");
774
+ }
775
+ return attachPaginationStorage(result, {
776
+ collectionKey: storages[0].collectionKey,
777
+ candidateCount: storages.reduce((sum, entry) => sum + entry.candidateCount, 0),
778
+ parts: storages,
779
+ });
780
+ }
781
+
746
782
  function aggregatePageResult({ operation, lastPage, checkpointPath, state }) {
747
783
  const last = lastPage || { ok: true, status: "success", data: {} };
748
- const lastData = { ...(last.data || {}) };
784
+ const lastData = { ...(last.data || {}), ...state.summaryData };
749
785
  delete lastData[operation.pagination.collectionKey];
750
786
  const lastPagination = { ...(last.data?.pagination || {}) };
751
787
  const pageScannedCount = normalizeScannedCount(
@@ -809,6 +845,17 @@ function aggregatePageResult({ operation, lastPage, checkpointPath, state }) {
809
845
  });
810
846
  }
811
847
 
848
+ function retainedSummaryData(operation, data) {
849
+ return Object.fromEntries(
850
+ (operation.pagination.summaryKeys || []).map((key) => {
851
+ if (!data || !Object.hasOwn(data, key)) {
852
+ throw validationError(`History scan is missing ${key} summary metadata. Start a fresh scan.`);
853
+ }
854
+ return [key, data[key]];
855
+ }),
856
+ );
857
+ }
858
+
812
859
  export async function runAutomaticPagination(args) {
813
860
  const { options, operation, runId } = args;
814
861
  if (!operation.pagination) {
@@ -929,6 +976,7 @@ async function runAutomaticPaginationWithLock({
929
976
  filterSummariesComplete: true,
930
977
  clientFilter: null,
931
978
  operatorViewFilter: null,
979
+ summaryData: {},
932
980
  resumed: false,
933
981
  updatedAt: new Date().toISOString(),
934
982
  };
@@ -1059,6 +1107,7 @@ async function runAutomaticPaginationWithLock({
1059
1107
  savedPages > 0 && saved?.filterSummariesComplete === true
1060
1108
  ? normalizeOperatorViewFilterSummary(saved?.operatorViewFilter)
1061
1109
  : null,
1110
+ summaryData: savedPages > 0 ? retainedSummaryData(operation, saved.summaryData) : {},
1062
1111
  resumed: true,
1063
1112
  };
1064
1113
  spoolHash = verifyCandidateSpool({
@@ -1215,6 +1264,7 @@ async function runAutomaticPaginationWithLock({
1215
1264
  spoolSha256: appended.spoolSha256,
1216
1265
  clientFilter,
1217
1266
  operatorViewFilter,
1267
+ summaryData: retainedSummaryData(operation, data),
1218
1268
  nextCursor,
1219
1269
  exhausted: pagination.exhausted,
1220
1270
  updatedAt: new Date().toISOString(),