@stage5/lumine 0.2.68 → 0.2.70
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 +23 -6
- package/lib/admin-featured-history.js +185 -0
- package/lib/admin-featured.js +33 -0
- package/lib/admin-runtime-logs.js +45 -6
- package/lib/admin-workflows.js +52 -2
- package/lib/admin.js +269 -87
- package/lib/assets.js +11 -8
- package/lib/build-review.js +609 -8
- package/lib/commands.js +18 -9
- package/lib/constants.js +26 -1
- package/lib/sdk.js +114 -4
- package/lib/sponsor-duty.js +15 -2
- package/lib/thumbnail.js +2 -2
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +72 -6
- package/sdk/LUMINE_ADMIN.md +344 -21
package/README.md
CHANGED
|
@@ -158,6 +158,20 @@ Replay listing and status are available without exposing private playback
|
|
|
158
158
|
grants. A creator or app owner can remove one with
|
|
159
159
|
`lumine sdk call live.deleteReplay '{"replayId":"..."}' --allow-write`.
|
|
160
160
|
|
|
161
|
+
`Twinkle.rewards` is callable too, on the same server-verified endpoints the
|
|
162
|
+
published app uses — the CLI holds no award logic. `lumine sdk call
|
|
163
|
+
rewards.getStatus '{}' --build <id>` is read-only and works for the build owner
|
|
164
|
+
without `--allow-write` (the endpoint accepts only the `rewards:claim` scope,
|
|
165
|
+
which is minted for it, but only the status operation is sent).
|
|
166
|
+
`rewards.start '{"ruleId":"..."}'` and
|
|
167
|
+
`rewards.claim '{"challengeId":"...","answers":[1,2]}'` mutate real XP/Coins
|
|
168
|
+
state and require `--allow-write`. Every rewards call first reads the
|
|
169
|
+
published-runtime reward grant from the canonical `GET /build/:id/runtime`
|
|
170
|
+
payload for the signed-in account; when the server issues none (draft,
|
|
171
|
+
private, unapproved or superseded release — the cases where the app itself
|
|
172
|
+
sees preview mode) the call stops with an explanatory error and nothing is
|
|
173
|
+
sent. `--path api/rewards/...` is refused like every other curated endpoint.
|
|
174
|
+
|
|
161
175
|
## Assets and AI image generation
|
|
162
176
|
|
|
163
177
|
Binary media never lives in the workspace — assets are uploaded to Twinkle and
|
|
@@ -166,19 +180,22 @@ audio, and MIDI data (`.mid`/`.midi`; playback still needs an app-side parser
|
|
|
166
180
|
or synth); `lumine assets list` prints your uploads and refreshes
|
|
167
181
|
`.twinkle/assets.json`.
|
|
168
182
|
|
|
169
|
-
`lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana>` creates
|
|
183
|
+
`lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>` creates
|
|
170
184
|
an AI-generated image asset instead of uploading one. `--model` is required
|
|
171
|
-
(
|
|
172
|
-
|
|
173
|
-
estimated cost and asks for confirmation first — non-interactive
|
|
174
|
-
|
|
185
|
+
(Flare = fast generation; Sunburst = precise editing; gpt-image-2 = original
|
|
186
|
+
model; nano-banana = Gemini). Generation spends your Twinkle AI Battery, so the
|
|
187
|
+
CLI shows the estimated cost and asks for confirmation first — non-interactive
|
|
188
|
+
runs must pass
|
|
189
|
+
`--yes` to consent. `--quality low|medium|high|xhigh|max` applies to GPT Image
|
|
190
|
+
models; `xhigh` and `max` require a 2.5 model. Estimates describe image output;
|
|
191
|
+
actual battery usage also includes prompt and reference input.
|
|
175
192
|
|
|
176
193
|
## Thumbnails
|
|
177
194
|
|
|
178
195
|
`lumine thumbnail set <file>` uploads a jpg/png/webp (max 8MB) as the build's
|
|
179
196
|
thumbnail. `lumine thumbnail capture` screenshots the running app server-side
|
|
180
197
|
and sets the result (add `--out <file>` to keep a local copy).
|
|
181
|
-
`lumine thumbnail generate ["<prompt>"] --model <gpt-image-2|nano-banana>`
|
|
198
|
+
`lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>`
|
|
182
199
|
generates an AI image and sets it as the thumbnail (the image is also kept as a
|
|
183
200
|
normal reusable asset); without a prompt the server composes one from the build
|
|
184
201
|
title and description. Replacing an existing thumbnail asks for confirmation;
|
|
@@ -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
|
+
}
|
package/lib/admin-featured.js
CHANGED
|
@@ -88,6 +88,14 @@ export function readFeaturedSelections(file) {
|
|
|
88
88
|
});
|
|
89
89
|
positive(raw?.reviewId, "Review ID");
|
|
90
90
|
positive(raw?.coverageId, "Reviewed coverage ID");
|
|
91
|
+
// Review and coverage receipts are distinct audit rows, so equal IDs can
|
|
92
|
+
// only mean the review ID was copied into coverageId. Catch that locally
|
|
93
|
+
// instead of letting the server reject an unrelated-looking receipt.
|
|
94
|
+
if (raw.coverageId === raw.reviewId) {
|
|
95
|
+
throw invalid(
|
|
96
|
+
`coverageId ${raw.coverageId} is the review ID, not the acknowledged coverage receipt. Use data.reviewedCoverage.id from \`featured comments acknowledge --reviewed\` (or its data.decisionsTemplate / --decisions-template file).`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
91
99
|
if (!Array.isArray(raw.selections) || raw.selections.length > 20_000) {
|
|
92
100
|
throw invalid(
|
|
93
101
|
"selections must be an array of at most 20000 agent-selected comments.",
|
|
@@ -530,6 +538,29 @@ export async function runFeaturedWorkflow({
|
|
|
530
538
|
return call(`/reviews/${state.review.id}/report`);
|
|
531
539
|
}
|
|
532
540
|
const subjects = Object.values(state.subjects);
|
|
541
|
+
// The recommend step needs the review ID plus the acknowledged coverage
|
|
542
|
+
// receipt ID (never the review ID); hand back a ready-to-fill file so the
|
|
543
|
+
// agent does not assemble those identifiers by hand.
|
|
544
|
+
const decisionsTemplate = state.coverage
|
|
545
|
+
? {
|
|
546
|
+
reviewId: state.review.id,
|
|
547
|
+
coverageId: state.coverage.id,
|
|
548
|
+
selections: [],
|
|
549
|
+
}
|
|
550
|
+
: null;
|
|
551
|
+
let decisionsTemplatePath = null;
|
|
552
|
+
if (options.adminDecisionsTemplate) {
|
|
553
|
+
if (workflow !== "acknowledge")
|
|
554
|
+
throw invalid(
|
|
555
|
+
"--decisions-template is written by `featured comments acknowledge --reviewed`.",
|
|
556
|
+
);
|
|
557
|
+
positive(decisionsTemplate?.coverageId, "Acknowledged coverage ID");
|
|
558
|
+
decisionsTemplatePath = path.resolve(options.adminDecisionsTemplate);
|
|
559
|
+
writeAdminJsonFile(decisionsTemplatePath, decisionsTemplate, {
|
|
560
|
+
privateFile: true,
|
|
561
|
+
maxBytes: MAX_BYTES,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
533
564
|
return {
|
|
534
565
|
ok: true,
|
|
535
566
|
status: "success",
|
|
@@ -537,6 +568,8 @@ export async function runFeaturedWorkflow({
|
|
|
537
568
|
data: {
|
|
538
569
|
review: state.review,
|
|
539
570
|
checkpointPath: checkpoint,
|
|
571
|
+
decisionsTemplate,
|
|
572
|
+
decisionsTemplatePath,
|
|
540
573
|
fetched: {
|
|
541
574
|
complete: subjects.every((item) => item.complete),
|
|
542
575
|
subjectsCompleted: subjects.filter((item) => item.complete).length,
|
|
@@ -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,
|
|
@@ -556,6 +562,21 @@ async function recoverPendingSnapshot({ options, authToken, session }) {
|
|
|
556
562
|
return { status, recoveredArtifact };
|
|
557
563
|
}
|
|
558
564
|
|
|
565
|
+
// Only `completed` closes a review. `needs_review` and
|
|
566
|
+
// `post_clear_review_required` both hand back another snapshot that must be
|
|
567
|
+
// read before rerunning finish, so the top-level status says so instead of
|
|
568
|
+
// reporting `success` for an unfinished review.
|
|
569
|
+
export function completionStatusEnvelope(result) {
|
|
570
|
+
const completionStatus = String(result?.data?.completionStatus || "");
|
|
571
|
+
if (
|
|
572
|
+
completionStatus === "needs_review" ||
|
|
573
|
+
completionStatus === "post_clear_review_required"
|
|
574
|
+
) {
|
|
575
|
+
return { ...result, status: "needs_review" };
|
|
576
|
+
}
|
|
577
|
+
return result;
|
|
578
|
+
}
|
|
579
|
+
|
|
559
580
|
function scrubLeaseToken(result, session, artifact) {
|
|
560
581
|
const review = result?.data?.review || {};
|
|
561
582
|
const { leaseToken: _leaseToken, ...safeReview } = review;
|
|
@@ -581,10 +602,14 @@ export async function runAdminRuntimeLogWorkflow({
|
|
|
581
602
|
}) {
|
|
582
603
|
const apiUrl = normalizeApiUrl(options.apiUrl);
|
|
583
604
|
if (operation.runtimeLogAction === "start") {
|
|
605
|
+
const host = operation.runtimeLogHost || "primary";
|
|
606
|
+
if (!["primary", "target"].includes(host)) {
|
|
607
|
+
throw validationError("Invalid runtime-log host.");
|
|
608
|
+
}
|
|
584
609
|
const intentPath = startIntentPath(options);
|
|
585
610
|
const intent = options.idempotencyKey
|
|
586
611
|
? null
|
|
587
|
-
: readStartIntent(intentPath, apiUrl);
|
|
612
|
+
: readStartIntent(intentPath, apiUrl, host);
|
|
588
613
|
const startRequestId = intent?.requestId || requestId;
|
|
589
614
|
if (!intent) {
|
|
590
615
|
writeAdminJsonFile(
|
|
@@ -592,6 +617,7 @@ export async function runAdminRuntimeLogWorkflow({
|
|
|
592
617
|
{
|
|
593
618
|
kind: "lumine-admin-runtime-log-review-start",
|
|
594
619
|
apiUrl,
|
|
620
|
+
host,
|
|
595
621
|
requestId: startRequestId,
|
|
596
622
|
createdAt: new Date().toISOString(),
|
|
597
623
|
},
|
|
@@ -602,7 +628,7 @@ export async function runAdminRuntimeLogWorkflow({
|
|
|
602
628
|
try {
|
|
603
629
|
result = await requestJson({
|
|
604
630
|
method: "POST",
|
|
605
|
-
url: `${apiUrl}/cli/admin/runtime-logs/reviews`,
|
|
631
|
+
url: `${apiUrl}/cli/admin/runtime-logs${operation.runtimeLogHost ? `/hosts/${host}` : ""}/reviews`,
|
|
606
632
|
authToken,
|
|
607
633
|
body: {},
|
|
608
634
|
headers: requestHeaders({ requestId: startRequestId }),
|
|
@@ -631,6 +657,15 @@ export async function runAdminRuntimeLogWorkflow({
|
|
|
631
657
|
throw error;
|
|
632
658
|
}
|
|
633
659
|
const review = validateReview(result?.data?.review);
|
|
660
|
+
if (
|
|
661
|
+
operation.runtimeLogHost &&
|
|
662
|
+
(!/^i-[a-f0-9]{17}$/.test(review.ownerHostId || "") ||
|
|
663
|
+
review.ownerHostRole !== host)
|
|
664
|
+
) {
|
|
665
|
+
throw validationError(
|
|
666
|
+
"The API did not confirm a host-owned review. Preserve the start intent and recover it; do not clear logs.",
|
|
667
|
+
);
|
|
668
|
+
}
|
|
634
669
|
const session = createSession({ options, apiUrl, review });
|
|
635
670
|
// The session now holds the lease; a later rerun must start fresh.
|
|
636
671
|
clearStartIntent(intentPath);
|
|
@@ -722,13 +757,13 @@ export async function runAdminRuntimeLogWorkflow({
|
|
|
722
757
|
});
|
|
723
758
|
if (recovered.recoveredArtifact) {
|
|
724
759
|
return scrubLeaseToken(
|
|
725
|
-
{
|
|
760
|
+
completionStatusEnvelope({
|
|
726
761
|
...recovered.status,
|
|
727
762
|
data: {
|
|
728
763
|
...(recovered.status.data || {}),
|
|
729
764
|
completionStatus: "needs_review",
|
|
730
765
|
},
|
|
731
|
-
},
|
|
766
|
+
}),
|
|
732
767
|
session,
|
|
733
768
|
recovered.recoveredArtifact,
|
|
734
769
|
);
|
|
@@ -782,5 +817,9 @@ export async function runAdminRuntimeLogWorkflow({
|
|
|
782
817
|
}
|
|
783
818
|
session.status = String(result?.data?.review?.status || session.status);
|
|
784
819
|
writeSession(session);
|
|
785
|
-
return scrubLeaseToken(
|
|
820
|
+
return scrubLeaseToken(
|
|
821
|
+
action === "complete" ? completionStatusEnvelope(result) : result,
|
|
822
|
+
session,
|
|
823
|
+
artifact,
|
|
824
|
+
);
|
|
786
825
|
}
|
package/lib/admin-workflows.js
CHANGED
|
@@ -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(),
|