@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 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
- (gpt-image-2 = best quality, slower, pricier; nano-banana = Gemini, faster,
172
- cheaper). Generation spends your Twinkle AI Battery, so the CLI shows the
173
- estimated cost and asks for confirmation first — non-interactive runs must pass
174
- `--yes` to consent. `--quality low|medium|high` applies to gpt-image-2 only.
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
+ }
@@ -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(result, session, artifact);
820
+ return scrubLeaseToken(
821
+ action === "complete" ? completionStatusEnvelope(result) : result,
822
+ session,
823
+ artifact,
824
+ );
786
825
  }
@@ -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(),