@stage5/lumine 0.2.66 → 0.2.68
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 +26 -1
- package/lib/admin-featured.js +559 -0
- package/lib/admin-runtime-logs.js +786 -0
- package/lib/admin-workflows.js +121 -46
- package/lib/admin.js +474 -40
- package/lib/commands.js +52 -8
- package/lib/sponsor-duty.js +479 -28
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +2 -2
- package/sdk/LUMINE_ADMIN.md +515 -83
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
createReadStream,
|
|
6
|
+
existsSync,
|
|
7
|
+
fsyncSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
openSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
|
|
21
|
+
import { requestJson } from "./http.js";
|
|
22
|
+
|
|
23
|
+
const SESSION_SCHEMA_VERSION = 1;
|
|
24
|
+
const MAX_SESSION_BYTES = 2 * 1024 * 1024;
|
|
25
|
+
const CHUNK_BYTES = 512 * 1024;
|
|
26
|
+
// The API bounds complete error captures at 256 MB and every normal-output
|
|
27
|
+
// segment at an 8 MB tail (up to 32 files), so this client ceiling only guards
|
|
28
|
+
// against a malformed manifest, never a legitimate snapshot.
|
|
29
|
+
const MAX_SNAPSHOT_BYTES = 512 * 1024 * 1024;
|
|
30
|
+
const START_INTENT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
const REQUEST_ID = /^[A-Za-z0-9._:-]{8,80}$/;
|
|
32
|
+
const MAX_SNAPSHOT_SEGMENTS = 32;
|
|
33
|
+
const UUID =
|
|
34
|
+
/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
|
35
|
+
const LOG_FILE_NAME = /^[A-Za-z0-9._-]+\.(?:err|out)\.log$/;
|
|
36
|
+
|
|
37
|
+
function validationError(message) {
|
|
38
|
+
const error = new Error(message);
|
|
39
|
+
error.code = "CLI_ADMIN_CLI_VALIDATION";
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requestHeaders({ requestId, leaseToken }) {
|
|
44
|
+
return {
|
|
45
|
+
...(requestId ? { "x-lumine-idempotency-key": requestId } : {}),
|
|
46
|
+
...(leaseToken ? { "x-lumine-admin-runtime-log-token": leaseToken } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function deterministicRequestId(action, ...parts) {
|
|
51
|
+
const digest = createHash("sha256")
|
|
52
|
+
.update(parts.map((part) => String(part || "")).join("\n"))
|
|
53
|
+
.digest("hex")
|
|
54
|
+
.slice(0, 40);
|
|
55
|
+
return `cli:runtime-logs:${action}:${digest}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeApiUrl(value) {
|
|
59
|
+
return String(value || "").replace(/\/$/, "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validateReview(value) {
|
|
63
|
+
const id = Number(value?.id || 0);
|
|
64
|
+
const leaseToken = String(value?.leaseToken || "").trim();
|
|
65
|
+
if (!Number.isSafeInteger(id) || id <= 0 || !UUID.test(leaseToken)) {
|
|
66
|
+
throw validationError(
|
|
67
|
+
"The API did not return a valid production-log review lease.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return { ...value, id, leaseToken };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readSession(filePath, apiUrl) {
|
|
74
|
+
const resolved = path.resolve(String(filePath || "").trim());
|
|
75
|
+
if (!String(filePath || "").trim()) {
|
|
76
|
+
throw validationError(
|
|
77
|
+
"Pass the production-log review session with --review-session <file>.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const session = readAdminJsonFile(
|
|
81
|
+
resolved,
|
|
82
|
+
"the runtime-log review session",
|
|
83
|
+
{
|
|
84
|
+
maxBytes: MAX_SESSION_BYTES,
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
if (
|
|
88
|
+
session?.schemaVersion !== SESSION_SCHEMA_VERSION ||
|
|
89
|
+
session?.kind !== "lumine-admin-runtime-log-review" ||
|
|
90
|
+
!Number.isSafeInteger(Number(session.reviewId)) ||
|
|
91
|
+
Number(session.reviewId) <= 0 ||
|
|
92
|
+
!UUID.test(String(session.leaseToken || ""))
|
|
93
|
+
) {
|
|
94
|
+
throw validationError(
|
|
95
|
+
"The runtime-log review session is missing its review ID or lease token.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (normalizeApiUrl(session.apiUrl) !== normalizeApiUrl(apiUrl)) {
|
|
99
|
+
throw validationError(
|
|
100
|
+
"The runtime-log review session belongs to a different API origin.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return { ...session, sessionPath: resolved };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeSession(session) {
|
|
107
|
+
const { sessionPath, ...stored } = session;
|
|
108
|
+
writeAdminJsonFile(
|
|
109
|
+
sessionPath,
|
|
110
|
+
{ ...stored, updatedAt: new Date().toISOString() },
|
|
111
|
+
{ privateFile: true, maxBytes: MAX_SESSION_BYTES },
|
|
112
|
+
);
|
|
113
|
+
return session;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A `start` response carries the only copy of the lease token. If it is lost
|
|
117
|
+
// in transit the server still holds an active review, so the request key is
|
|
118
|
+
// persisted BEFORE the request goes out; rerunning `start` replays that key
|
|
119
|
+
// and the server answers idempotently with the same review.
|
|
120
|
+
function startIntentPath(options) {
|
|
121
|
+
const session = String(options.adminReviewSession || "").trim();
|
|
122
|
+
if (session) return `${path.resolve(session)}.start-intent.json`;
|
|
123
|
+
const outputDirectory = String(options.adminOutputDir || "").trim();
|
|
124
|
+
if (outputDirectory) {
|
|
125
|
+
return path.join(
|
|
126
|
+
path.resolve(outputDirectory),
|
|
127
|
+
"runtime-log-review-start-intent.json",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
// Shared temp directories are per host, not per operator login; key the
|
|
131
|
+
// fallback to this account so two operator accounts never replay each
|
|
132
|
+
// other's start key. Two shells of the same account still share it, which
|
|
133
|
+
// is why a replayed start rotates the lease server-side.
|
|
134
|
+
const account = createHash("sha256")
|
|
135
|
+
.update(`${os.userInfo().username}\n${os.homedir()}`)
|
|
136
|
+
.digest("hex")
|
|
137
|
+
.slice(0, 16);
|
|
138
|
+
return path.join(
|
|
139
|
+
os.tmpdir(),
|
|
140
|
+
`lumine-admin-runtime-log-review-start-intent-${account}.json`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readStartIntent(intentPath, apiUrl) {
|
|
145
|
+
if (!existsSync(intentPath)) return null;
|
|
146
|
+
const intent = readAdminJsonFile(
|
|
147
|
+
intentPath,
|
|
148
|
+
"the runtime-log review start intent",
|
|
149
|
+
{ maxBytes: MAX_SESSION_BYTES },
|
|
150
|
+
);
|
|
151
|
+
if (
|
|
152
|
+
intent?.kind !== "lumine-admin-runtime-log-review-start" ||
|
|
153
|
+
normalizeApiUrl(intent.apiUrl) !== normalizeApiUrl(apiUrl) ||
|
|
154
|
+
!REQUEST_ID.test(String(intent.requestId || "")) ||
|
|
155
|
+
!(Date.now() - Date.parse(intent.createdAt || "") < START_INTENT_MAX_AGE_MS)
|
|
156
|
+
) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
return intent;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function clearStartIntent(intentPath) {
|
|
163
|
+
rmSync(intentPath, { force: true });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function createSession({ options, apiUrl, review }) {
|
|
167
|
+
const requestedOutputDirectory = String(options.adminOutputDir || "").trim();
|
|
168
|
+
const outputDirectory = requestedOutputDirectory
|
|
169
|
+
? ensurePrivateDirectory(
|
|
170
|
+
path.resolve(
|
|
171
|
+
requestedOutputDirectory,
|
|
172
|
+
`lumine-admin-runtime-log-review-${review.id}`,
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
: mkdtempSync(
|
|
176
|
+
path.join(os.tmpdir(), `lumine-admin-runtime-log-review-${review.id}-`),
|
|
177
|
+
);
|
|
178
|
+
chmodSync(outputDirectory, 0o700);
|
|
179
|
+
const sessionPath = path.resolve(
|
|
180
|
+
String(options.adminReviewSession || "").trim() ||
|
|
181
|
+
path.join(outputDirectory, "review-session.json"),
|
|
182
|
+
);
|
|
183
|
+
return writeSession({
|
|
184
|
+
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
185
|
+
kind: "lumine-admin-runtime-log-review",
|
|
186
|
+
apiUrl,
|
|
187
|
+
reviewId: review.id,
|
|
188
|
+
leaseToken: review.leaseToken,
|
|
189
|
+
status: review.status,
|
|
190
|
+
outputDirectory,
|
|
191
|
+
latestSnapshotId: null,
|
|
192
|
+
latestAcknowledgedSnapshotId: null,
|
|
193
|
+
artifacts: [],
|
|
194
|
+
sessionPath,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function hashFile(filePath) {
|
|
199
|
+
const hash = createHash("sha256");
|
|
200
|
+
let bytes = 0;
|
|
201
|
+
for await (const chunk of createReadStream(filePath)) {
|
|
202
|
+
hash.update(chunk);
|
|
203
|
+
bytes += chunk.length;
|
|
204
|
+
}
|
|
205
|
+
return { sha256: hash.digest("hex"), byteLength: bytes };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function safeLogName(value) {
|
|
209
|
+
const normalized = path.basename(String(value || "log"));
|
|
210
|
+
return normalized.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function ensurePrivateDirectory(directory) {
|
|
214
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
215
|
+
const metadata = lstatSync(directory);
|
|
216
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
217
|
+
throw validationError(
|
|
218
|
+
`Runtime-log artifact path ${directory} must be a real directory, not a link.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
chmodSync(directory, 0o700);
|
|
222
|
+
return directory;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function validateSnapshot(value) {
|
|
226
|
+
const snapshot = value;
|
|
227
|
+
if (
|
|
228
|
+
!snapshot ||
|
|
229
|
+
snapshot.schemaVersion !== 1 ||
|
|
230
|
+
!UUID.test(String(snapshot.snapshotId || "")) ||
|
|
231
|
+
!Number.isSafeInteger(Number(snapshot.sequence)) ||
|
|
232
|
+
Number(snapshot.sequence) <= 0 ||
|
|
233
|
+
!["baseline", "delta", "post_clear"].includes(snapshot.phase) ||
|
|
234
|
+
!Array.isArray(snapshot.segments) ||
|
|
235
|
+
snapshot.segments.length > MAX_SNAPSHOT_SEGMENTS ||
|
|
236
|
+
!Array.isArray(snapshot.missingFiles)
|
|
237
|
+
) {
|
|
238
|
+
throw validationError(
|
|
239
|
+
"The API response is missing a valid production-log snapshot manifest.",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const segmentIds = new Set();
|
|
243
|
+
let totalBytes = 0;
|
|
244
|
+
for (const segment of snapshot.segments) {
|
|
245
|
+
const start = Number(segment?.startOffset);
|
|
246
|
+
const end = Number(segment?.endOffsetExclusive);
|
|
247
|
+
const byteLength = Number(segment?.byteLength);
|
|
248
|
+
if (
|
|
249
|
+
!UUID.test(String(segment?.segmentId || "")) ||
|
|
250
|
+
segmentIds.has(segment.segmentId) ||
|
|
251
|
+
!LOG_FILE_NAME.test(String(segment?.fileName || "")) ||
|
|
252
|
+
!Number.isSafeInteger(start) ||
|
|
253
|
+
start < 0 ||
|
|
254
|
+
!Number.isSafeInteger(end) ||
|
|
255
|
+
end < start ||
|
|
256
|
+
!Number.isSafeInteger(byteLength) ||
|
|
257
|
+
byteLength !== end - start ||
|
|
258
|
+
!/^[a-f0-9]{64}$/.test(String(segment?.sha256 || ""))
|
|
259
|
+
) {
|
|
260
|
+
throw validationError(
|
|
261
|
+
"The API returned an invalid production-log segment boundary.",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
segmentIds.add(segment.segmentId);
|
|
265
|
+
totalBytes += byteLength;
|
|
266
|
+
if (totalBytes > MAX_SNAPSHOT_BYTES) {
|
|
267
|
+
throw validationError(
|
|
268
|
+
`The API returned a production-log snapshot above the ${MAX_SNAPSHOT_BYTES / (1024 * 1024)} MiB client safety limit.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
!Number.isSafeInteger(Number(snapshot.totalBytes)) ||
|
|
274
|
+
Number(snapshot.totalBytes) !== totalBytes
|
|
275
|
+
) {
|
|
276
|
+
throw validationError(
|
|
277
|
+
"The API returned an inconsistent production-log snapshot byte total.",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return snapshot;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function downloadSegment({
|
|
284
|
+
options,
|
|
285
|
+
authToken,
|
|
286
|
+
session,
|
|
287
|
+
snapshot,
|
|
288
|
+
segment,
|
|
289
|
+
segmentIndex,
|
|
290
|
+
snapshotPath,
|
|
291
|
+
}) {
|
|
292
|
+
const fileName = `${String(segmentIndex + 1).padStart(2, "0")}-${safeLogName(segment.fileName)}-${segment.startOffset}-${segment.endOffsetExclusive}.log`;
|
|
293
|
+
const finalPath = path.join(snapshotPath, fileName);
|
|
294
|
+
if (existsSync(finalPath)) {
|
|
295
|
+
const metadata = lstatSync(finalPath);
|
|
296
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
297
|
+
throw validationError(
|
|
298
|
+
`Existing runtime-log artifact ${finalPath} must be a regular file, not a link.`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const existing = await hashFile(finalPath);
|
|
302
|
+
if (
|
|
303
|
+
existing.byteLength === Number(segment.byteLength) &&
|
|
304
|
+
existing.sha256 === String(segment.sha256)
|
|
305
|
+
) {
|
|
306
|
+
chmodSync(finalPath, 0o600);
|
|
307
|
+
return {
|
|
308
|
+
segmentId: segment.segmentId,
|
|
309
|
+
fileName: segment.fileName,
|
|
310
|
+
path: finalPath,
|
|
311
|
+
...existing,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
throw validationError(
|
|
315
|
+
`Existing runtime-log artifact ${finalPath} does not match the server manifest. Preserve it and choose a different --output-dir.`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const temporary = `${finalPath}.tmp-${process.pid}-${randomUUID()}`;
|
|
320
|
+
const descriptor = openSync(temporary, "wx", 0o600);
|
|
321
|
+
const hash = createHash("sha256");
|
|
322
|
+
let offset = 0;
|
|
323
|
+
try {
|
|
324
|
+
while (offset < Number(segment.byteLength)) {
|
|
325
|
+
const result = await requestJson({
|
|
326
|
+
url:
|
|
327
|
+
`${normalizeApiUrl(options.apiUrl)}/cli/admin/runtime-logs/reviews/${session.reviewId}` +
|
|
328
|
+
`/snapshots/${snapshot.snapshotId}/segments/${segment.segmentId}` +
|
|
329
|
+
`?offset=${offset}&limit=${CHUNK_BYTES}`,
|
|
330
|
+
authToken,
|
|
331
|
+
headers: requestHeaders({ leaseToken: session.leaseToken }),
|
|
332
|
+
timeoutMs: options.timeoutMs,
|
|
333
|
+
});
|
|
334
|
+
const chunk = result?.data?.chunk;
|
|
335
|
+
if (
|
|
336
|
+
Number(chunk?.offset) !== offset ||
|
|
337
|
+
String(chunk?.segmentId || "") !== String(segment.segmentId)
|
|
338
|
+
) {
|
|
339
|
+
throw validationError(
|
|
340
|
+
"The API returned a production-log chunk for the wrong boundary.",
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const bytes = Buffer.from(String(chunk.contentBase64 || ""), "base64");
|
|
344
|
+
if (
|
|
345
|
+
bytes.length !== Number(chunk.byteLength) ||
|
|
346
|
+
Number(chunk.nextOffset) !== offset + bytes.length ||
|
|
347
|
+
(bytes.length === 0 && offset < Number(segment.byteLength))
|
|
348
|
+
) {
|
|
349
|
+
throw validationError(
|
|
350
|
+
"The API returned an invalid production-log chunk length.",
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
let written = 0;
|
|
354
|
+
while (written < bytes.length) {
|
|
355
|
+
const count = writeSync(
|
|
356
|
+
descriptor,
|
|
357
|
+
bytes,
|
|
358
|
+
written,
|
|
359
|
+
bytes.length - written,
|
|
360
|
+
offset + written,
|
|
361
|
+
);
|
|
362
|
+
if (count <= 0) {
|
|
363
|
+
throw new Error("The runtime-log artifact write made no progress.");
|
|
364
|
+
}
|
|
365
|
+
written += count;
|
|
366
|
+
}
|
|
367
|
+
hash.update(bytes);
|
|
368
|
+
offset += bytes.length;
|
|
369
|
+
}
|
|
370
|
+
const sha256 = hash.digest("hex");
|
|
371
|
+
if (
|
|
372
|
+
offset !== Number(segment.byteLength) ||
|
|
373
|
+
sha256 !== String(segment.sha256)
|
|
374
|
+
) {
|
|
375
|
+
throw validationError(
|
|
376
|
+
`Downloaded runtime-log artifact ${finalPath} does not match its canonical digest.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
fsyncSync(descriptor);
|
|
380
|
+
closeSync(descriptor);
|
|
381
|
+
renameSync(temporary, finalPath);
|
|
382
|
+
chmodSync(finalPath, 0o600);
|
|
383
|
+
return {
|
|
384
|
+
segmentId: segment.segmentId,
|
|
385
|
+
fileName: segment.fileName,
|
|
386
|
+
path: finalPath,
|
|
387
|
+
byteLength: offset,
|
|
388
|
+
sha256,
|
|
389
|
+
};
|
|
390
|
+
} catch (error) {
|
|
391
|
+
try {
|
|
392
|
+
closeSync(descriptor);
|
|
393
|
+
} catch {
|
|
394
|
+
// The successful path or failed write may already have closed it.
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
unlinkSync(temporary);
|
|
398
|
+
} catch {
|
|
399
|
+
// Exclusive create may have failed before a temporary file existed.
|
|
400
|
+
}
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function materializeSnapshot({ options, authToken, session, snapshot }) {
|
|
406
|
+
snapshot = validateSnapshot(snapshot);
|
|
407
|
+
const outputDirectory = path.resolve(session.outputDirectory);
|
|
408
|
+
const snapshotPath = path.join(
|
|
409
|
+
outputDirectory,
|
|
410
|
+
`snapshot-${snapshot.sequence}-${snapshot.snapshotId}`,
|
|
411
|
+
);
|
|
412
|
+
ensurePrivateDirectory(snapshotPath);
|
|
413
|
+
const artifacts = [];
|
|
414
|
+
for (const [index, segment] of snapshot.segments.entries()) {
|
|
415
|
+
artifacts.push(
|
|
416
|
+
await downloadSegment({
|
|
417
|
+
options,
|
|
418
|
+
authToken,
|
|
419
|
+
session,
|
|
420
|
+
snapshot,
|
|
421
|
+
segment,
|
|
422
|
+
segmentIndex: index,
|
|
423
|
+
snapshotPath,
|
|
424
|
+
}),
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
const manifestPath = path.join(snapshotPath, "manifest.json");
|
|
428
|
+
writeAdminJsonFile(manifestPath, snapshot, {
|
|
429
|
+
privateFile: true,
|
|
430
|
+
maxBytes: MAX_SESSION_BYTES,
|
|
431
|
+
});
|
|
432
|
+
return { snapshotPath, manifestPath, artifacts };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function acknowledgeSnapshot({
|
|
436
|
+
options,
|
|
437
|
+
authToken,
|
|
438
|
+
session,
|
|
439
|
+
snapshot,
|
|
440
|
+
materialized,
|
|
441
|
+
}) {
|
|
442
|
+
await requestJson({
|
|
443
|
+
method: "POST",
|
|
444
|
+
url:
|
|
445
|
+
`${normalizeApiUrl(options.apiUrl)}/cli/admin/runtime-logs/reviews/${session.reviewId}` +
|
|
446
|
+
`/snapshots/${snapshot.snapshotId}/acknowledge`,
|
|
447
|
+
authToken,
|
|
448
|
+
body: {
|
|
449
|
+
receipts: materialized.artifacts.map((artifact) => ({
|
|
450
|
+
segmentId: artifact.segmentId,
|
|
451
|
+
byteLength: artifact.byteLength,
|
|
452
|
+
sha256: artifact.sha256,
|
|
453
|
+
})),
|
|
454
|
+
},
|
|
455
|
+
headers: requestHeaders({
|
|
456
|
+
leaseToken: session.leaseToken,
|
|
457
|
+
requestId: deterministicRequestId(
|
|
458
|
+
"ack",
|
|
459
|
+
session.reviewId,
|
|
460
|
+
snapshot.snapshotId,
|
|
461
|
+
),
|
|
462
|
+
}),
|
|
463
|
+
timeoutMs: options.timeoutMs,
|
|
464
|
+
});
|
|
465
|
+
return recordSnapshotArtifact({ session, snapshot, materialized });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function recordSnapshotArtifact({ session, snapshot, materialized }) {
|
|
469
|
+
session.latestSnapshotId = snapshot.snapshotId;
|
|
470
|
+
session.latestAcknowledgedSnapshotId = snapshot.snapshotId;
|
|
471
|
+
const artifact = {
|
|
472
|
+
snapshotId: snapshot.snapshotId,
|
|
473
|
+
sequence: snapshot.sequence,
|
|
474
|
+
phase: snapshot.phase,
|
|
475
|
+
capturedAt: snapshot.capturedAt,
|
|
476
|
+
totalBytes: snapshot.totalBytes,
|
|
477
|
+
boundaryLosses: snapshot.segments
|
|
478
|
+
.filter((segment) => segment.boundaryLost)
|
|
479
|
+
.map((segment) => ({
|
|
480
|
+
fileName: segment.fileName,
|
|
481
|
+
reason: segment.boundaryLossReason,
|
|
482
|
+
previousBoundary: segment.previousBoundary,
|
|
483
|
+
currentBoundary: segment.currentBoundary,
|
|
484
|
+
})),
|
|
485
|
+
missingFiles: snapshot.missingFiles,
|
|
486
|
+
...materialized,
|
|
487
|
+
};
|
|
488
|
+
session.artifacts = [
|
|
489
|
+
...(Array.isArray(session.artifacts)
|
|
490
|
+
? session.artifacts.filter(
|
|
491
|
+
(entry) => entry?.snapshotId !== snapshot.snapshotId,
|
|
492
|
+
)
|
|
493
|
+
: []),
|
|
494
|
+
artifact,
|
|
495
|
+
];
|
|
496
|
+
writeSession(session);
|
|
497
|
+
return artifact;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function recoverPendingSnapshot({ options, authToken, session }) {
|
|
501
|
+
const status = await requestJson({
|
|
502
|
+
url: `${normalizeApiUrl(options.apiUrl)}/cli/admin/runtime-logs/reviews/${session.reviewId}`,
|
|
503
|
+
authToken,
|
|
504
|
+
headers: requestHeaders({ leaseToken: session.leaseToken }),
|
|
505
|
+
timeoutMs: options.timeoutMs,
|
|
506
|
+
});
|
|
507
|
+
const review = status?.data?.review;
|
|
508
|
+
if (review && String(review.status || "") !== "active") {
|
|
509
|
+
// Completed, abandoned, expired, or failed elsewhere: the server has
|
|
510
|
+
// released this review's artifacts, so downloading would only 409.
|
|
511
|
+
session.status = String(review.status);
|
|
512
|
+
writeSession(session);
|
|
513
|
+
return { status, recoveredArtifact: null, inactive: true };
|
|
514
|
+
}
|
|
515
|
+
const snapshot = review?.latestSnapshot;
|
|
516
|
+
let recoveredArtifact = null;
|
|
517
|
+
if (snapshot && review.latestSnapshotAcknowledged !== true) {
|
|
518
|
+
const materialized = await materializeSnapshot({
|
|
519
|
+
options,
|
|
520
|
+
authToken,
|
|
521
|
+
session,
|
|
522
|
+
snapshot,
|
|
523
|
+
});
|
|
524
|
+
recoveredArtifact = await acknowledgeSnapshot({
|
|
525
|
+
options,
|
|
526
|
+
authToken,
|
|
527
|
+
session,
|
|
528
|
+
snapshot,
|
|
529
|
+
materialized,
|
|
530
|
+
});
|
|
531
|
+
} else if (snapshot && review.latestSnapshotAcknowledged === true) {
|
|
532
|
+
const recorded = Array.isArray(session.artifacts)
|
|
533
|
+
? session.artifacts.find(
|
|
534
|
+
(entry) => entry?.snapshotId === snapshot.snapshotId,
|
|
535
|
+
)
|
|
536
|
+
: null;
|
|
537
|
+
if (!recorded) {
|
|
538
|
+
const materialized = await materializeSnapshot({
|
|
539
|
+
options,
|
|
540
|
+
authToken,
|
|
541
|
+
session,
|
|
542
|
+
snapshot,
|
|
543
|
+
});
|
|
544
|
+
recoveredArtifact = recordSnapshotArtifact({
|
|
545
|
+
session,
|
|
546
|
+
snapshot,
|
|
547
|
+
materialized,
|
|
548
|
+
});
|
|
549
|
+
} else {
|
|
550
|
+
session.latestSnapshotId = snapshot.snapshotId;
|
|
551
|
+
session.latestAcknowledgedSnapshotId = snapshot.snapshotId;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
session.status = String(review?.status || session.status || "active");
|
|
555
|
+
writeSession(session);
|
|
556
|
+
return { status, recoveredArtifact };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function scrubLeaseToken(result, session, artifact) {
|
|
560
|
+
const review = result?.data?.review || {};
|
|
561
|
+
const { leaseToken: _leaseToken, ...safeReview } = review;
|
|
562
|
+
return {
|
|
563
|
+
...result,
|
|
564
|
+
data: {
|
|
565
|
+
...(result.data || {}),
|
|
566
|
+
review: safeReview,
|
|
567
|
+
artifacts: {
|
|
568
|
+
reviewSessionPath: session.sessionPath,
|
|
569
|
+
outputDirectory: session.outputDirectory,
|
|
570
|
+
latestSnapshot: artifact || null,
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export async function runAdminRuntimeLogWorkflow({
|
|
577
|
+
options,
|
|
578
|
+
operation,
|
|
579
|
+
authToken,
|
|
580
|
+
requestId,
|
|
581
|
+
}) {
|
|
582
|
+
const apiUrl = normalizeApiUrl(options.apiUrl);
|
|
583
|
+
if (operation.runtimeLogAction === "start") {
|
|
584
|
+
const intentPath = startIntentPath(options);
|
|
585
|
+
const intent = options.idempotencyKey
|
|
586
|
+
? null
|
|
587
|
+
: readStartIntent(intentPath, apiUrl);
|
|
588
|
+
const startRequestId = intent?.requestId || requestId;
|
|
589
|
+
if (!intent) {
|
|
590
|
+
writeAdminJsonFile(
|
|
591
|
+
intentPath,
|
|
592
|
+
{
|
|
593
|
+
kind: "lumine-admin-runtime-log-review-start",
|
|
594
|
+
apiUrl,
|
|
595
|
+
requestId: startRequestId,
|
|
596
|
+
createdAt: new Date().toISOString(),
|
|
597
|
+
},
|
|
598
|
+
{ privateFile: true, maxBytes: MAX_SESSION_BYTES },
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
let result;
|
|
602
|
+
try {
|
|
603
|
+
result = await requestJson({
|
|
604
|
+
method: "POST",
|
|
605
|
+
url: `${apiUrl}/cli/admin/runtime-logs/reviews`,
|
|
606
|
+
authToken,
|
|
607
|
+
body: {},
|
|
608
|
+
headers: requestHeaders({ requestId: startRequestId }),
|
|
609
|
+
timeoutMs: options.timeoutMs,
|
|
610
|
+
});
|
|
611
|
+
} catch (error) {
|
|
612
|
+
const status = Number(error?.status || 0);
|
|
613
|
+
// Transport failures, timeouts, and 5xx leave the server outcome
|
|
614
|
+
// unknown: keep the key so a rerun replays it. A 4xx is the server's
|
|
615
|
+
// definitive answer for this key, so replaying it can never succeed.
|
|
616
|
+
if (!(status >= 400 && status < 500)) throw error;
|
|
617
|
+
clearStartIntent(intentPath);
|
|
618
|
+
if (
|
|
619
|
+
intent &&
|
|
620
|
+
error?.data?.error?.code === "CLI_ADMIN_RUNTIME_LOG_REVIEW_NOT_ACTIVE"
|
|
621
|
+
) {
|
|
622
|
+
// The persisted key belongs to a review that already finished or
|
|
623
|
+
// failed; start over once with a fresh key.
|
|
624
|
+
return runAdminRuntimeLogWorkflow({
|
|
625
|
+
options,
|
|
626
|
+
operation,
|
|
627
|
+
authToken,
|
|
628
|
+
requestId: `cli:${randomUUID()}`,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
const review = validateReview(result?.data?.review);
|
|
634
|
+
const session = createSession({ options, apiUrl, review });
|
|
635
|
+
// The session now holds the lease; a later rerun must start fresh.
|
|
636
|
+
clearStartIntent(intentPath);
|
|
637
|
+
const snapshot = result?.data?.snapshot;
|
|
638
|
+
const materialized = await materializeSnapshot({
|
|
639
|
+
options,
|
|
640
|
+
authToken,
|
|
641
|
+
session,
|
|
642
|
+
snapshot,
|
|
643
|
+
});
|
|
644
|
+
const artifact = await acknowledgeSnapshot({
|
|
645
|
+
options,
|
|
646
|
+
authToken,
|
|
647
|
+
session,
|
|
648
|
+
snapshot,
|
|
649
|
+
materialized,
|
|
650
|
+
});
|
|
651
|
+
return scrubLeaseToken(result, session, artifact);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (operation.runtimeLogAction === "resume") {
|
|
655
|
+
const result = await requestJson({
|
|
656
|
+
method: "POST",
|
|
657
|
+
url: `${apiUrl}/cli/admin/runtime-logs/reviews/resume`,
|
|
658
|
+
authToken,
|
|
659
|
+
body: {},
|
|
660
|
+
headers: requestHeaders({ requestId }),
|
|
661
|
+
timeoutMs: options.timeoutMs,
|
|
662
|
+
});
|
|
663
|
+
const review = validateReview(result?.data?.review);
|
|
664
|
+
const session = createSession({ options, apiUrl, review });
|
|
665
|
+
clearStartIntent(startIntentPath(options));
|
|
666
|
+
const snapshot = result?.data?.snapshot;
|
|
667
|
+
let artifact = null;
|
|
668
|
+
if (snapshot) {
|
|
669
|
+
const materialized = await materializeSnapshot({
|
|
670
|
+
options,
|
|
671
|
+
authToken,
|
|
672
|
+
session,
|
|
673
|
+
snapshot,
|
|
674
|
+
});
|
|
675
|
+
artifact =
|
|
676
|
+
review.latestSnapshotAcknowledged === true
|
|
677
|
+
? recordSnapshotArtifact({ session, snapshot, materialized })
|
|
678
|
+
: await acknowledgeSnapshot({
|
|
679
|
+
options,
|
|
680
|
+
authToken,
|
|
681
|
+
session,
|
|
682
|
+
snapshot,
|
|
683
|
+
materialized,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
return scrubLeaseToken(result, session, artifact);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (operation.runtimeLogAction === "abandon") {
|
|
690
|
+
const sessionPath = String(options.adminReviewSession || "").trim();
|
|
691
|
+
const session = sessionPath ? readSession(sessionPath, apiUrl) : null;
|
|
692
|
+
const result = await requestJson({
|
|
693
|
+
method: "POST",
|
|
694
|
+
url: `${apiUrl}/cli/admin/runtime-logs/reviews/abandon`,
|
|
695
|
+
authToken,
|
|
696
|
+
body: session ? { reviewId: session.reviewId } : {},
|
|
697
|
+
headers: requestHeaders({}),
|
|
698
|
+
timeoutMs: options.timeoutMs,
|
|
699
|
+
});
|
|
700
|
+
clearStartIntent(startIntentPath(options));
|
|
701
|
+
if (!session) return result;
|
|
702
|
+
session.status = String(result?.data?.review?.status || "abandoned");
|
|
703
|
+
writeSession(session);
|
|
704
|
+
return scrubLeaseToken(result, session, null);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const session = readSession(options.adminReviewSession, apiUrl);
|
|
708
|
+
if (operation.runtimeLogAction === "status") {
|
|
709
|
+
const result = await requestJson({
|
|
710
|
+
url: `${apiUrl}/cli/admin/runtime-logs/reviews/${session.reviewId}`,
|
|
711
|
+
authToken,
|
|
712
|
+
headers: requestHeaders({ leaseToken: session.leaseToken }),
|
|
713
|
+
timeoutMs: options.timeoutMs,
|
|
714
|
+
});
|
|
715
|
+
return scrubLeaseToken(result, session, null);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const recovered = await recoverPendingSnapshot({
|
|
719
|
+
options,
|
|
720
|
+
authToken,
|
|
721
|
+
session,
|
|
722
|
+
});
|
|
723
|
+
if (recovered.recoveredArtifact) {
|
|
724
|
+
return scrubLeaseToken(
|
|
725
|
+
{
|
|
726
|
+
...recovered.status,
|
|
727
|
+
data: {
|
|
728
|
+
...(recovered.status.data || {}),
|
|
729
|
+
completionStatus: "needs_review",
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
session,
|
|
733
|
+
recovered.recoveredArtifact,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
if (recovered.inactive) {
|
|
737
|
+
return scrubLeaseToken(
|
|
738
|
+
{
|
|
739
|
+
...recovered.status,
|
|
740
|
+
status: "already_done",
|
|
741
|
+
changed: false,
|
|
742
|
+
data: {
|
|
743
|
+
...(recovered.status.data || {}),
|
|
744
|
+
completionStatus: String(recovered.status.data.review.status),
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
session,
|
|
748
|
+
null,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
const action = operation.runtimeLogAction;
|
|
752
|
+
const result = await requestJson({
|
|
753
|
+
method: "POST",
|
|
754
|
+
url: `${apiUrl}/cli/admin/runtime-logs/reviews/${session.reviewId}/${action}`,
|
|
755
|
+
authToken,
|
|
756
|
+
body:
|
|
757
|
+
action === "complete"
|
|
758
|
+
? { reviewedSnapshotId: session.latestAcknowledgedSnapshotId }
|
|
759
|
+
: {},
|
|
760
|
+
headers: requestHeaders({
|
|
761
|
+
leaseToken: session.leaseToken,
|
|
762
|
+
requestId,
|
|
763
|
+
}),
|
|
764
|
+
timeoutMs: options.timeoutMs,
|
|
765
|
+
});
|
|
766
|
+
const snapshot = result?.data?.snapshot;
|
|
767
|
+
let artifact = null;
|
|
768
|
+
if (snapshot) {
|
|
769
|
+
const materialized = await materializeSnapshot({
|
|
770
|
+
options,
|
|
771
|
+
authToken,
|
|
772
|
+
session,
|
|
773
|
+
snapshot,
|
|
774
|
+
});
|
|
775
|
+
artifact = await acknowledgeSnapshot({
|
|
776
|
+
options,
|
|
777
|
+
authToken,
|
|
778
|
+
session,
|
|
779
|
+
snapshot,
|
|
780
|
+
materialized,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
session.status = String(result?.data?.review?.status || session.status);
|
|
784
|
+
writeSession(session);
|
|
785
|
+
return scrubLeaseToken(result, session, artifact);
|
|
786
|
+
}
|