@stage5/lumine 0.3.4 → 0.3.6
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/bin/lumine.js +6 -2
- package/lib/admin-daily.js +215 -43
- package/lib/admin-duties.js +191 -0
- package/lib/admin-request-intents.js +83 -0
- package/lib/admin-runtime-logs.js +7 -3
- package/lib/admin-work-directory.js +11 -0
- package/lib/admin-workflows.js +116 -22
- package/lib/admin.js +58 -10
- package/lib/auth.js +1 -0
- package/lib/build-review.js +3 -3
- package/lib/commands.js +2 -0
- package/lib/http.js +17 -2
- package/lib/sdk.js +10 -0
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +18 -2
- package/sdk/LUMINE_ADMIN.md +40 -4
package/bin/lumine.js
CHANGED
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
import { main } from "../lib/commands.js";
|
|
3
3
|
import { formatAdminJsonError, isAdminJsonInvocation } from "../lib/admin.js";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
try {
|
|
6
|
+
// Node exits nonzero when top-level await cannot finish, instead of silently
|
|
7
|
+
// treating an unfinished command with no remaining handles as exit 0.
|
|
8
|
+
await main();
|
|
9
|
+
} catch (error) {
|
|
6
10
|
if (isAdminJsonInvocation(process.argv.slice(2))) {
|
|
7
11
|
console.log(JSON.stringify(formatAdminJsonError(error)));
|
|
8
12
|
} else {
|
|
9
13
|
console.error(`lumine: ${error?.message || error}`);
|
|
10
14
|
}
|
|
11
15
|
process.exitCode = 1;
|
|
12
|
-
}
|
|
16
|
+
}
|
package/lib/admin-daily.js
CHANGED
|
@@ -3,6 +3,11 @@ import path from "path";
|
|
|
3
3
|
import http from "http";
|
|
4
4
|
import { spawn, spawnSync } from "child_process";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { adminWorkDirectory } from "./admin-work-directory.js";
|
|
8
|
+
import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
|
|
9
|
+
import { createDailyReviewState, dailyRunProgress } from "./admin-duties.js";
|
|
10
|
+
export { dailyRunProgress };
|
|
6
11
|
|
|
7
12
|
// Multi-step conveniences for the full daily management run. Each one runs
|
|
8
13
|
// the ordinary `lumine admin` commands as child processes, so pagination,
|
|
@@ -14,10 +19,16 @@ const MARKED_ASSET = fileURLToPath(
|
|
|
14
19
|
new URL("./report-assets/marked.min.js", import.meta.url),
|
|
15
20
|
);
|
|
16
21
|
|
|
17
|
-
function runAdmin(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
export function runAdmin(
|
|
23
|
+
args,
|
|
24
|
+
{ outputFile, extraEnv, retryRead = false, spawnImpl = spawnSync } = {},
|
|
25
|
+
) {
|
|
26
|
+
const attemptFile = outputFile
|
|
27
|
+
? `${outputFile}.attempt-${randomUUID()}.json`
|
|
28
|
+
: null;
|
|
29
|
+
const full = ["admin", ...args, "--json", "--no-update-check"];
|
|
30
|
+
if (attemptFile) full.push("--output", attemptFile);
|
|
31
|
+
const result = spawnImpl(process.execPath, [LUMINE_BIN, ...full], {
|
|
21
32
|
encoding: "utf8",
|
|
22
33
|
maxBuffer: 256 * 1024 * 1024,
|
|
23
34
|
env: { ...process.env, ...(extraEnv || {}) },
|
|
@@ -28,10 +39,72 @@ function runAdmin(args, { outputFile, extraEnv } = {}) {
|
|
|
28
39
|
} catch {
|
|
29
40
|
parsed = null;
|
|
30
41
|
}
|
|
42
|
+
let error = null;
|
|
43
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.ok !== "boolean" || typeof parsed.status !== "string") {
|
|
44
|
+
error = {
|
|
45
|
+
code: "CLI_ADMIN_OUTPUT_INVALID",
|
|
46
|
+
message: "The child did not return a valid admin JSON receipt.",
|
|
47
|
+
};
|
|
48
|
+
} else if (attemptFile && result.status === 0 && parsed.ok === true) {
|
|
49
|
+
try {
|
|
50
|
+
const saved = JSON.parse(fs.readFileSync(attemptFile, "utf8"));
|
|
51
|
+
if (JSON.stringify(saved) !== JSON.stringify(parsed))
|
|
52
|
+
throw new Error("mismatch");
|
|
53
|
+
if (parsed.data?.scan?.outputPath === attemptFile)
|
|
54
|
+
parsed.data.scan.outputPath = outputFile;
|
|
55
|
+
writeAdminJsonFile(outputFile, parsed, { privateFile: true });
|
|
56
|
+
fs.unlinkSync(attemptFile);
|
|
57
|
+
} catch {
|
|
58
|
+
error = {
|
|
59
|
+
code: "CLI_ADMIN_OUTPUT_MISSING",
|
|
60
|
+
message: "The child did not save the matching JSON receipt.",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const ok =
|
|
65
|
+
result.status === 0 && parsed?.ok === true && !error && !result.error;
|
|
66
|
+
const reads = {
|
|
67
|
+
"energy-budget": [null],
|
|
68
|
+
news: [null],
|
|
69
|
+
brief: [null],
|
|
70
|
+
"ai-costs": ["monthly", "day"],
|
|
71
|
+
"media-costs": ["monthly"],
|
|
72
|
+
"reward-activity": [null],
|
|
73
|
+
"reward-review": ["list"],
|
|
74
|
+
todo: ["list"],
|
|
75
|
+
escalation: ["list"],
|
|
76
|
+
featured: ["list", "history"],
|
|
77
|
+
sponsor: ["applications"],
|
|
78
|
+
"bot-output": [null],
|
|
79
|
+
subjects: ["candidates"],
|
|
80
|
+
subject: ["get"],
|
|
81
|
+
};
|
|
82
|
+
const subcommand = args[1]?.startsWith("--") ? null : (args[1] ?? null);
|
|
83
|
+
const readOnly =
|
|
84
|
+
reads[args[0]]?.includes(subcommand) &&
|
|
85
|
+
(args[0] !== "sponsor" || args[2] === "list");
|
|
86
|
+
// Only explicitly opted-in read collections retry an unconfirmed result.
|
|
87
|
+
// Mutation recovery keeps its original idempotency key and lease instead.
|
|
88
|
+
const transient =
|
|
89
|
+
error ||
|
|
90
|
+
result.error ||
|
|
91
|
+
result.signal ||
|
|
92
|
+
parsed?.error?.code === "lumine_http_invalid_response";
|
|
93
|
+
if (!ok && retryRead && readOnly && transient) {
|
|
94
|
+
const retried = runAdmin(args, { outputFile, extraEnv, spawnImpl });
|
|
95
|
+
return {
|
|
96
|
+
...retried,
|
|
97
|
+
attempts: 2,
|
|
98
|
+
recovered: retried.ok,
|
|
99
|
+
firstError:
|
|
100
|
+
error || parsed?.error || String(result.error || result.signal),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
31
103
|
return {
|
|
32
|
-
ok
|
|
104
|
+
ok,
|
|
105
|
+
attempts: 1,
|
|
33
106
|
status: result.status,
|
|
34
|
-
json: parsed,
|
|
107
|
+
json: error ? { ok: false, status: "unconfirmed", error } : parsed,
|
|
35
108
|
stderr: String(result.stderr || "").slice(-2000),
|
|
36
109
|
};
|
|
37
110
|
}
|
|
@@ -91,11 +164,13 @@ export async function dailyRunGather(options) {
|
|
|
91
164
|
const steps = [];
|
|
92
165
|
const step = (name, args, file) => {
|
|
93
166
|
const outputFile = path.join(dir, file);
|
|
94
|
-
const res = runAdmin(args, { outputFile });
|
|
167
|
+
const res = runAdmin(args, { outputFile, retryRead: true });
|
|
95
168
|
steps.push({
|
|
96
169
|
name,
|
|
97
170
|
file,
|
|
98
171
|
ok: res.ok,
|
|
172
|
+
attempts: res.attempts,
|
|
173
|
+
recovered: Boolean(res.recovered),
|
|
99
174
|
...(res.ok ? {} : { error: res.json?.error || res.stderr }),
|
|
100
175
|
});
|
|
101
176
|
return res;
|
|
@@ -175,6 +250,15 @@ export async function dailyRunGather(options) {
|
|
|
175
250
|
JSON.stringify(summary, null, 2),
|
|
176
251
|
{ mode: 0o600 },
|
|
177
252
|
);
|
|
253
|
+
// Collection never acknowledges reading or completes an editorial duty.
|
|
254
|
+
const reviewStateFile = path.join(dir, "review-state.json");
|
|
255
|
+
const previousReviewState = fs.existsSync(reviewStateFile)
|
|
256
|
+
? readAdminJsonFile(reviewStateFile, "the previous review state") : {};
|
|
257
|
+
writeAdminJsonFile(
|
|
258
|
+
reviewStateFile,
|
|
259
|
+
createDailyReviewState(dir, summary, previousReviewState),
|
|
260
|
+
{ privateFile: true },
|
|
261
|
+
);
|
|
178
262
|
return {
|
|
179
263
|
ok: summary.failed.length === 0,
|
|
180
264
|
status: summary.failed.length ? "partial_failure" : "success",
|
|
@@ -188,20 +272,26 @@ export async function featuredCandidates(options) {
|
|
|
188
272
|
const days = Math.max(1, Math.min(30, Number(options.adminDays || 7)));
|
|
189
273
|
const after = options.adminAfter || bangkokCutoff(days);
|
|
190
274
|
const scratch = ensureDir(
|
|
191
|
-
path.resolve(
|
|
275
|
+
path.resolve(
|
|
276
|
+
options.adminOutputDir ||
|
|
277
|
+
fs.mkdtempSync(path.join(adminWorkDirectory(), "featured-")),
|
|
278
|
+
),
|
|
192
279
|
);
|
|
193
280
|
const subjects = runAdmin(
|
|
194
281
|
["subjects", "candidates", "--after", after, "--all"],
|
|
195
|
-
{ outputFile: path.join(scratch, "subjects.json") },
|
|
282
|
+
{ outputFile: path.join(scratch, "subjects.json"), retryRead: true },
|
|
196
283
|
);
|
|
197
284
|
if (!subjects.ok) throw new Error(`subjects candidates failed: ${JSON.stringify(subjects.json?.error || subjects.stderr)}`);
|
|
198
|
-
const board = runAdmin(["featured", "list"]);
|
|
285
|
+
const board = runAdmin(["featured", "list"], { retryRead: true });
|
|
199
286
|
if (!board.ok) throw new Error("featured list failed");
|
|
200
287
|
const onBoard = new Set((board.json.data.subjects || []).map((s) => s.id));
|
|
201
288
|
const recent = (subjects.json.data.subjects || []).filter((s) => !onBoard.has(s.id));
|
|
202
289
|
const ids = recent.map((s) => s.id);
|
|
203
290
|
const history = ids.length
|
|
204
|
-
? runAdmin(
|
|
291
|
+
? runAdmin(
|
|
292
|
+
["featured", "history", "--subject-ids", ids.join(","), "--all"],
|
|
293
|
+
{ retryRead: true },
|
|
294
|
+
)
|
|
205
295
|
: { ok: true, json: { data: { subjects: [], coverage: {} } } };
|
|
206
296
|
if (!history.ok) throw new Error("featured history failed");
|
|
207
297
|
const never = new Map(
|
|
@@ -209,7 +299,14 @@ export async function featuredCandidates(options) {
|
|
|
209
299
|
);
|
|
210
300
|
const candidates = [];
|
|
211
301
|
for (const s of recent) {
|
|
212
|
-
const detail = runAdmin(
|
|
302
|
+
const detail = runAdmin(
|
|
303
|
+
["subject", "get", String(s.id), "--include-comments"],
|
|
304
|
+
{ retryRead: true },
|
|
305
|
+
);
|
|
306
|
+
if (!detail.ok)
|
|
307
|
+
throw new Error(
|
|
308
|
+
`Subject ${s.id} context is unavailable; candidate review is incomplete.`,
|
|
309
|
+
);
|
|
213
310
|
const subject = detail.json?.data?.subject || {};
|
|
214
311
|
const humanComments = (subject.comments || []).filter(
|
|
215
312
|
(c) => !c.isNotification && ![7587, 8411].includes(Number(c.author?.id)),
|
|
@@ -226,6 +323,9 @@ export async function featuredCandidates(options) {
|
|
|
226
323
|
hasAttachment: Boolean(s.attachment),
|
|
227
324
|
description: String(s.description || "").slice(0, 300),
|
|
228
325
|
commentsIncluded: Boolean(subject.commentsIncluded),
|
|
326
|
+
commentsTruncated: Boolean(subject.commentsTruncated),
|
|
327
|
+
commentCountsComplete:
|
|
328
|
+
Boolean(subject.commentsIncluded) && !subject.commentsTruncated,
|
|
229
329
|
humanComments: humanComments.length,
|
|
230
330
|
botComments: (subject.comments || []).filter((c) =>
|
|
231
331
|
[7587, 8411].includes(Number(c.author?.id)),
|
|
@@ -262,13 +362,68 @@ code{background:var(--code);padding:1px 5px;border-radius:4px;font-size:.9em}pre
|
|
|
262
362
|
@media (max-width:760px){nav{position:static;width:auto;height:auto;border-right:0;border-bottom:1px solid var(--line)}main{margin-left:0;padding:16px}}
|
|
263
363
|
</style></head><body><nav><h4>Sections</h4><div id="toc"></div><h4 style="margin-top:18px">Source</h4><a href="source.html">Markdown</a></nav><main id="content">Loading…</main>
|
|
264
364
|
<script src="marked.min.js"></script><script>
|
|
265
|
-
fetch('daily-management-report.md',{cache:'no-store'}).then(r=>r.text()).then(md=>{const el=document.getElementById('content');el.innerHTML=marked.parse(md);const toc=document.getElementById('toc');let i=0;el.querySelectorAll('h1,h2').forEach(h=>{h.id='s'+(i++);if(h.tagName==='H2'){const a=document.createElement('a');a.href='#'+h.id;a.textContent=h.textContent;toc.appendChild(a)}})});
|
|
365
|
+
fetch('daily-management-report.md',{cache:'no-store'}).then(r=>{if(!r.ok)throw new Error('Report source unavailable');return r.text()}).then(md=>{const el=document.getElementById('content');el.innerHTML=marked.parse(md);const toc=document.getElementById('toc');let i=0;el.querySelectorAll('h1,h2').forEach(h=>{h.id='s'+(i++);if(h.tagName==='H2'){const a=document.createElement('a');a.href='#'+h.id;a.textContent=h.textContent;toc.appendChild(a)}})}).catch(()=>{document.getElementById('content').textContent='The report could not load. Open Markdown under Source, or reload this page.'});
|
|
266
366
|
</script></body></html>`;
|
|
267
367
|
}
|
|
268
368
|
|
|
269
369
|
// Serves the report Markdown as a browsable page on loopback. Only the page,
|
|
270
370
|
// its script and the Markdown are reachable; the evidence folder is not.
|
|
271
|
-
export
|
|
371
|
+
export function waitForReportServer(child, timeoutMs = 10000) {
|
|
372
|
+
return new Promise((resolve, reject) => {
|
|
373
|
+
let buf = "";
|
|
374
|
+
let settled = false;
|
|
375
|
+
const finish = (error, url) => {
|
|
376
|
+
if (settled) return;
|
|
377
|
+
settled = true;
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
child.stdout.removeListener("data", onData);
|
|
380
|
+
child.removeListener("exit", onExit);
|
|
381
|
+
child.removeListener("error", onError);
|
|
382
|
+
if (error) {
|
|
383
|
+
child.kill();
|
|
384
|
+
reject(error);
|
|
385
|
+
} else resolve(url);
|
|
386
|
+
};
|
|
387
|
+
const onData = (chunk) => {
|
|
388
|
+
buf += chunk;
|
|
389
|
+
if (buf.length > 65536)
|
|
390
|
+
return finish(
|
|
391
|
+
new Error("Report server returned an invalid startup receipt."),
|
|
392
|
+
);
|
|
393
|
+
if (!buf.includes("\n")) return;
|
|
394
|
+
try {
|
|
395
|
+
const parsed = JSON.parse(buf.split("\n")[0]);
|
|
396
|
+
const url = new URL(parsed.data?.url);
|
|
397
|
+
if (
|
|
398
|
+
parsed.ok !== true ||
|
|
399
|
+
parsed.status !== "success" ||
|
|
400
|
+
url.protocol !== "http:" ||
|
|
401
|
+
url.hostname !== "127.0.0.1"
|
|
402
|
+
)
|
|
403
|
+
throw new Error("invalid receipt");
|
|
404
|
+
finish(null, url.href);
|
|
405
|
+
} catch {
|
|
406
|
+
finish(new Error("Report server returned an invalid startup receipt."));
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
const onExit = (code) =>
|
|
410
|
+
finish(new Error(`Report server exited (${code}) before it was ready.`));
|
|
411
|
+
const onError = (error) => finish(error);
|
|
412
|
+
const timer = setTimeout(
|
|
413
|
+
() =>
|
|
414
|
+
finish(new Error("Report server did not start before its deadline.")),
|
|
415
|
+
timeoutMs,
|
|
416
|
+
);
|
|
417
|
+
child.stdout.on("data", onData);
|
|
418
|
+
child.once("exit", onExit);
|
|
419
|
+
child.once("error", onError);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function reportServe(
|
|
424
|
+
options,
|
|
425
|
+
{ spawnImpl = spawn, openImpl = spawnSync } = {},
|
|
426
|
+
) {
|
|
272
427
|
const file = path.resolve(options.adminFile || "daily-management-report.md");
|
|
273
428
|
if (!fs.existsSync(file)) throw new Error(`Report not found: ${file}`);
|
|
274
429
|
const title =
|
|
@@ -295,48 +450,65 @@ export async function reportServe(options) {
|
|
|
295
450
|
res.writeHead(404).end("not found");
|
|
296
451
|
return;
|
|
297
452
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
453
|
+
try {
|
|
454
|
+
const { type, body } = route();
|
|
455
|
+
res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
|
|
456
|
+
res.end(body);
|
|
457
|
+
} catch {
|
|
458
|
+
res.writeHead(503).end("report source unavailable");
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
await new Promise((resolve, reject) => {
|
|
462
|
+
server.once("error", reject);
|
|
463
|
+
server.listen(port, "127.0.0.1", resolve);
|
|
301
464
|
});
|
|
302
|
-
await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
|
|
303
465
|
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
304
466
|
console.log(JSON.stringify({ ok: true, status: "success", data: { url, file } }));
|
|
305
467
|
return new Promise(() => {});
|
|
306
468
|
}
|
|
307
469
|
// Detach a foreground copy of this command so the page outlives the CLI.
|
|
308
|
-
const child =
|
|
470
|
+
const child = spawnImpl(
|
|
309
471
|
process.execPath,
|
|
310
|
-
[
|
|
472
|
+
[
|
|
473
|
+
LUMINE_BIN,
|
|
474
|
+
"admin",
|
|
475
|
+
"report",
|
|
476
|
+
"serve",
|
|
477
|
+
"--file",
|
|
478
|
+
file,
|
|
479
|
+
"--port",
|
|
480
|
+
String(port),
|
|
481
|
+
"--foreground",
|
|
482
|
+
"--json",
|
|
483
|
+
"--no-update-check",
|
|
484
|
+
],
|
|
311
485
|
{ detached: true, stdio: ["ignore", "pipe", "ignore"] },
|
|
312
486
|
);
|
|
313
|
-
const url = await
|
|
314
|
-
let buf = "";
|
|
315
|
-
const timer = setTimeout(() => reject(new Error("report server did not start")), 10000);
|
|
316
|
-
child.stdout.on("data", (chunk) => {
|
|
317
|
-
buf += chunk;
|
|
318
|
-
const line = buf.split("\n")[0];
|
|
319
|
-
try {
|
|
320
|
-
const parsed = JSON.parse(line);
|
|
321
|
-
clearTimeout(timer);
|
|
322
|
-
resolve(parsed.data.url);
|
|
323
|
-
} catch {
|
|
324
|
-
/* wait for the full line */
|
|
325
|
-
}
|
|
326
|
-
});
|
|
327
|
-
child.on("exit", (code) => {
|
|
328
|
-
clearTimeout(timer);
|
|
329
|
-
reject(new Error(`report server exited (${code})`));
|
|
330
|
-
});
|
|
331
|
-
});
|
|
487
|
+
const url = await waitForReportServer(child);
|
|
332
488
|
child.stdout.destroy();
|
|
333
489
|
child.unref();
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
490
|
+
const opened = options.adminOpen
|
|
491
|
+
? openImpl("open", ["-a", "Google Chrome", url])
|
|
492
|
+
: null;
|
|
337
493
|
return {
|
|
338
494
|
ok: true,
|
|
339
495
|
status: "success",
|
|
340
|
-
data: {
|
|
496
|
+
data: {
|
|
497
|
+
url,
|
|
498
|
+
file,
|
|
499
|
+
pid: child.pid,
|
|
500
|
+
opened: opened?.status === 0,
|
|
501
|
+
...(opened && opened.status !== 0
|
|
502
|
+
? {
|
|
503
|
+
openError:
|
|
504
|
+
"Chrome did not accept the open request; the report server is available.",
|
|
505
|
+
}
|
|
506
|
+
: {}),
|
|
507
|
+
browserVerification: {
|
|
508
|
+
render: "pending",
|
|
509
|
+
navigation: "pending",
|
|
510
|
+
source: "pending",
|
|
511
|
+
},
|
|
512
|
+
},
|
|
341
513
|
};
|
|
342
514
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
|
|
5
|
+
|
|
6
|
+
function fingerprint(dir, file) {
|
|
7
|
+
const absolute = path.resolve(dir, file);
|
|
8
|
+
const relative = path.relative(dir, absolute);
|
|
9
|
+
if (
|
|
10
|
+
!relative ||
|
|
11
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
12
|
+
path.isAbsolute(relative)
|
|
13
|
+
) {
|
|
14
|
+
throw new Error("Duty evidence must name a file inside the run directory.");
|
|
15
|
+
}
|
|
16
|
+
const bytes = fs.readFileSync(absolute);
|
|
17
|
+
return {
|
|
18
|
+
file: relative,
|
|
19
|
+
bytes: bytes.length,
|
|
20
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createDailyReviewState(dir, gather, previous = {}) {
|
|
25
|
+
if (previous.protocol != null && previous.protocol !== 1) throw new Error("Unsupported previous duty evidence protocol.");
|
|
26
|
+
const sources = gather.steps
|
|
27
|
+
.filter((step) => step.file)
|
|
28
|
+
.map((step) => ({
|
|
29
|
+
...(step.ok ? fingerprint(dir, step.file) : { file: step.file }),
|
|
30
|
+
fetched: step.ok,
|
|
31
|
+
read: false,
|
|
32
|
+
}));
|
|
33
|
+
if (gather.botOutput.complete)
|
|
34
|
+
sources.push({
|
|
35
|
+
...fingerprint(dir, "bot-chats.txt"),
|
|
36
|
+
fetched: true,
|
|
37
|
+
read: false,
|
|
38
|
+
});
|
|
39
|
+
for (const source of previous.sources || []) {
|
|
40
|
+
if (!sources.some((current) => current.file === source.file)) sources.push({ ...source, retained: true, read: false });
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
protocol: 1,
|
|
44
|
+
scope: previous.scope || "collection",
|
|
45
|
+
sources,
|
|
46
|
+
requiredDuties: [...new Set(["effort-levels", "report-browser", ...(previous.requiredDuties || [])])],
|
|
47
|
+
note: "Collection is not a full review. Add every duty required by the run scope and its carryovers. Reading and browser checks require explicit evidence acknowledgments.",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Local operator acknowledgments are separate from canonical server mutations.
|
|
52
|
+
// Hashes bind an acknowledgment to the bytes read; they cannot prove reading.
|
|
53
|
+
export function summarizeDailyProgress(dir, state, evidence = {}) {
|
|
54
|
+
if (
|
|
55
|
+
state.protocol !== 1 ||
|
|
56
|
+
(evidence.protocol != null && evidence.protocol !== 1)
|
|
57
|
+
)
|
|
58
|
+
throw new Error("Unsupported duty evidence protocol.");
|
|
59
|
+
const acknowledgments = evidence.reading || [];
|
|
60
|
+
const sources = (state.sources || []).map((source) => {
|
|
61
|
+
if (!source.fetched)
|
|
62
|
+
return { ...source, read: false, status: "unavailable" };
|
|
63
|
+
let current;
|
|
64
|
+
try {
|
|
65
|
+
current = fingerprint(dir, source.file);
|
|
66
|
+
} catch {
|
|
67
|
+
return { ...source, read: false, status: "unavailable" };
|
|
68
|
+
}
|
|
69
|
+
const acknowledgment = acknowledgments.find(
|
|
70
|
+
(item) =>
|
|
71
|
+
item.file === source.file &&
|
|
72
|
+
item.sha256 === current.sha256 &&
|
|
73
|
+
item.bytes === current.bytes &&
|
|
74
|
+
Number.isFinite(Date.parse(item.readAt)) &&
|
|
75
|
+
item.complete === true,
|
|
76
|
+
);
|
|
77
|
+
return {
|
|
78
|
+
...source,
|
|
79
|
+
...current,
|
|
80
|
+
read: Boolean(acknowledgment),
|
|
81
|
+
status: acknowledgment ? "read" : "fetched",
|
|
82
|
+
readAt: acknowledgment?.readAt || null,
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
const verifyReference = (reference) => {
|
|
86
|
+
if (!reference?.file || !reference.sha256) return false;
|
|
87
|
+
try {
|
|
88
|
+
const current = fingerprint(dir, reference.file);
|
|
89
|
+
return (
|
|
90
|
+
current.sha256 === reference.sha256 && current.bytes === reference.bytes
|
|
91
|
+
);
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
let canonicalRun = null;
|
|
97
|
+
let effortAssignments = null;
|
|
98
|
+
if (verifyReference(evidence.runReport)) {
|
|
99
|
+
const receipt = readAdminJsonFile(
|
|
100
|
+
path.resolve(dir, evidence.runReport.file),
|
|
101
|
+
"the run report",
|
|
102
|
+
);
|
|
103
|
+
const report =
|
|
104
|
+
receipt.ok === true && receipt.status === "success"
|
|
105
|
+
? receipt.data?.report
|
|
106
|
+
: null;
|
|
107
|
+
if (
|
|
108
|
+
report &&
|
|
109
|
+
Number(report.run?.id) === Number(evidence.runId) &&
|
|
110
|
+
Array.isArray(report.mutations?.byAction)
|
|
111
|
+
) {
|
|
112
|
+
canonicalRun = { id: report.run.id, status: report.run.status };
|
|
113
|
+
// Absence is zero only in a confirmed complete report, never in a failed fetch.
|
|
114
|
+
const row = report.mutations.byAction.find(
|
|
115
|
+
(item) => item.action === "subject.effort.set",
|
|
116
|
+
);
|
|
117
|
+
effortAssignments = row || {
|
|
118
|
+
action: "subject.effort.set",
|
|
119
|
+
attempts: 0,
|
|
120
|
+
completed: 0,
|
|
121
|
+
changed: 0,
|
|
122
|
+
failed: 0,
|
|
123
|
+
pending: 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const required = [
|
|
128
|
+
...new Set([
|
|
129
|
+
...(state.requiredDuties || []),
|
|
130
|
+
...(evidence.requiredDuties || []),
|
|
131
|
+
]),
|
|
132
|
+
];
|
|
133
|
+
const duties = required.map((id) => {
|
|
134
|
+
const duty = (evidence.duties || []).find((item) => item.id === id);
|
|
135
|
+
const confirmed =
|
|
136
|
+
duty?.status === "completed" &&
|
|
137
|
+
duty.evidence?.length &&
|
|
138
|
+
duty.evidence.every(verifyReference) &&
|
|
139
|
+
(id !== "effort-levels" || effortAssignments !== null);
|
|
140
|
+
return {
|
|
141
|
+
id,
|
|
142
|
+
status: confirmed
|
|
143
|
+
? "completed"
|
|
144
|
+
: duty?.status === "unavailable"
|
|
145
|
+
? "unavailable"
|
|
146
|
+
: "pending",
|
|
147
|
+
note: duty?.note || null,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
const incomplete = duties
|
|
151
|
+
.filter((item) => item.status !== "completed")
|
|
152
|
+
.map((item) => item.id);
|
|
153
|
+
const complete =
|
|
154
|
+
sources.every((item) => item.read) && incomplete.length === 0;
|
|
155
|
+
return {
|
|
156
|
+
scope: state.scope || "declared-duties",
|
|
157
|
+
coverageBasis: "declared sources and duties only",
|
|
158
|
+
canonicalRun,
|
|
159
|
+
effortAssignments,
|
|
160
|
+
sources,
|
|
161
|
+
duties,
|
|
162
|
+
complete,
|
|
163
|
+
reading: {
|
|
164
|
+
fetched: sources.filter((item) => item.fetched).length,
|
|
165
|
+
read: sources.filter((item) => item.read).length,
|
|
166
|
+
pending: sources.filter((item) => !item.read).map((item) => item.file),
|
|
167
|
+
},
|
|
168
|
+
incompleteDuties: incomplete,
|
|
169
|
+
note: "Run lease completion, reading acknowledgments, effort mutations, and browser verification are independent. A completed lease does not close pending review duties.",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function dailyRunProgress(options) {
|
|
174
|
+
const dir = path.resolve(options.adminDir || options.adminOutputDir || ".");
|
|
175
|
+
const state = readAdminJsonFile(
|
|
176
|
+
path.join(dir, "review-state.json"),
|
|
177
|
+
"the review state",
|
|
178
|
+
);
|
|
179
|
+
const evidence = options.adminFile
|
|
180
|
+
? readAdminJsonFile(path.resolve(options.adminFile), "the duty evidence")
|
|
181
|
+
: {};
|
|
182
|
+
const progress = summarizeDailyProgress(dir, state, evidence);
|
|
183
|
+
writeAdminJsonFile(path.join(dir, "progress.json"), progress, {
|
|
184
|
+
privateFile: true,
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
status: progress.complete ? "success" : "pending",
|
|
189
|
+
data: progress,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { adminWorkDirectory } from "./admin-work-directory.js";
|
|
5
|
+
import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
|
|
6
|
+
|
|
7
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
8
|
+
|
|
9
|
+
// Persist before sending. A killed process or missing HTTP receipt reuses the
|
|
10
|
+
// same request key in the same API/account/run/session and exact operation.
|
|
11
|
+
// Only a canonical receipt closes it; there is no automatic write retry.
|
|
12
|
+
export function prepareAdminMutationIntent({
|
|
13
|
+
operation,
|
|
14
|
+
apiUrl,
|
|
15
|
+
authority,
|
|
16
|
+
idempotencyKey,
|
|
17
|
+
directory = path.join(adminWorkDirectory(), "requests"),
|
|
18
|
+
}) {
|
|
19
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
20
|
+
fs.chmodSync(directory, 0o700);
|
|
21
|
+
const fingerprint = digest(
|
|
22
|
+
JSON.stringify({
|
|
23
|
+
apiUrl: String(apiUrl).replace(/\/$/, ""),
|
|
24
|
+
authority,
|
|
25
|
+
method: operation.method,
|
|
26
|
+
path: operation.path,
|
|
27
|
+
body: operation.body,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
const suffix = idempotencyKey ? `-${digest(idempotencyKey)}` : "";
|
|
31
|
+
const file = path.join(directory, `${fingerprint}${suffix}.pending.json`);
|
|
32
|
+
let intent;
|
|
33
|
+
try {
|
|
34
|
+
const fd = fs.openSync(file, "wx", 0o600);
|
|
35
|
+
try {
|
|
36
|
+
intent = {
|
|
37
|
+
kind: "lumine-admin-mutation-intent",
|
|
38
|
+
fingerprint,
|
|
39
|
+
operation: operation.name,
|
|
40
|
+
requestId: idempotencyKey || `cli:${randomUUID()}`,
|
|
41
|
+
createdAt: new Date().toISOString(),
|
|
42
|
+
};
|
|
43
|
+
fs.writeFileSync(fd, JSON.stringify(intent));
|
|
44
|
+
fs.fsyncSync(fd);
|
|
45
|
+
} finally {
|
|
46
|
+
fs.closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code !== "EEXIST") throw error;
|
|
50
|
+
intent = readAdminJsonFile(file, "the pending mutation intent");
|
|
51
|
+
if (
|
|
52
|
+
intent.kind !== "lumine-admin-mutation-intent" ||
|
|
53
|
+
intent.fingerprint !== fingerprint ||
|
|
54
|
+
!/^[A-Za-z0-9._:-]{8,80}$/.test(intent.requestId || "")
|
|
55
|
+
) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`The pending mutation intent is unconfirmed: ${file}. Inspect its original request key before retrying.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const receiptPath = path.join(
|
|
62
|
+
directory,
|
|
63
|
+
`${digest(intent.requestId)}.receipt.json`,
|
|
64
|
+
);
|
|
65
|
+
return {
|
|
66
|
+
requestId: intent.requestId,
|
|
67
|
+
confirm(receipt) {
|
|
68
|
+
if (receipt?.ok !== true || typeof receipt.status !== "string") {
|
|
69
|
+
const error = new Error(
|
|
70
|
+
"The API did not return a canonical admin receipt. The mutation outcome is unconfirmed.",
|
|
71
|
+
);
|
|
72
|
+
error.code = "CLI_ADMIN_RECEIPT_INVALID";
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
writeAdminJsonFile(receiptPath, receipt, { privateFile: true });
|
|
76
|
+
},
|
|
77
|
+
delivered() {
|
|
78
|
+
fs.rmSync(file, { force: true });
|
|
79
|
+
},
|
|
80
|
+
file,
|
|
81
|
+
receiptPath,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -19,6 +19,7 @@ import path from "node:path";
|
|
|
19
19
|
|
|
20
20
|
import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
|
|
21
21
|
import { requestJson } from "./http.js";
|
|
22
|
+
import { adminWorkDirectory } from "./admin-work-directory.js";
|
|
22
23
|
|
|
23
24
|
const SESSION_SCHEMA_VERSION = 1;
|
|
24
25
|
const MAX_SESSION_BYTES = 2 * 1024 * 1024;
|
|
@@ -127,7 +128,7 @@ function startIntentPath(options) {
|
|
|
127
128
|
"runtime-log-review-start-intent.json",
|
|
128
129
|
);
|
|
129
130
|
}
|
|
130
|
-
//
|
|
131
|
+
// Persistent fallback files are also keyed by operator account; key the
|
|
131
132
|
// fallback to this account so two operator accounts never replay each
|
|
132
133
|
// other's start key. Two shells of the same account still share it, which
|
|
133
134
|
// is why a replayed start rotates the lease server-side.
|
|
@@ -136,7 +137,7 @@ function startIntentPath(options) {
|
|
|
136
137
|
.digest("hex")
|
|
137
138
|
.slice(0, 16);
|
|
138
139
|
return path.join(
|
|
139
|
-
|
|
140
|
+
adminWorkDirectory(),
|
|
140
141
|
`lumine-admin-runtime-log-review-start-intent-${account}.json`,
|
|
141
142
|
);
|
|
142
143
|
}
|
|
@@ -178,7 +179,10 @@ function createSession({ options, apiUrl, review }) {
|
|
|
178
179
|
),
|
|
179
180
|
)
|
|
180
181
|
: mkdtempSync(
|
|
181
|
-
path.join(
|
|
182
|
+
path.join(
|
|
183
|
+
adminWorkDirectory(),
|
|
184
|
+
`lumine-admin-runtime-log-review-${review.id}-`,
|
|
185
|
+
),
|
|
182
186
|
);
|
|
183
187
|
chmodSync(outputDirectory, 0o700);
|
|
184
188
|
const sessionPath = path.resolve(
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Recovery keys, leases and evidence survive OS temp cleanup. Explicit output
|
|
5
|
+
// directories, checkpoints and review-session paths still take priority.
|
|
6
|
+
export function adminWorkDirectory() {
|
|
7
|
+
const directory = path.resolve(process.cwd(), "work", "lumine-admin");
|
|
8
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
9
|
+
fs.chmodSync(directory, 0o700);
|
|
10
|
+
return directory;
|
|
11
|
+
}
|
package/lib/admin-workflows.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
chmodSync,
|
|
4
|
+
constants,
|
|
5
|
+
copyFileSync,
|
|
4
6
|
closeSync,
|
|
5
7
|
createReadStream,
|
|
6
8
|
fchmodSync,
|
|
@@ -12,6 +14,7 @@ import {
|
|
|
12
14
|
readFileSync,
|
|
13
15
|
readSync,
|
|
14
16
|
renameSync,
|
|
17
|
+
statSync,
|
|
15
18
|
truncateSync,
|
|
16
19
|
unlinkSync,
|
|
17
20
|
writeFileSync,
|
|
@@ -22,6 +25,7 @@ import readline from "node:readline";
|
|
|
22
25
|
|
|
23
26
|
import { writeAdminJsonFile, readAdminJsonFile } from "./admin-news.js";
|
|
24
27
|
import { requestJson } from "./http.js";
|
|
28
|
+
import { adminWorkDirectory } from "./admin-work-directory.js";
|
|
25
29
|
|
|
26
30
|
const MAX_BATCH_TARGET_FILE_BYTES = 2 * 1024 * 1024;
|
|
27
31
|
const MAX_BATCH_TARGETS = 20_000;
|
|
@@ -80,13 +84,14 @@ function defaultCheckpointPath({
|
|
|
80
84
|
runId,
|
|
81
85
|
operationName,
|
|
82
86
|
operationFingerprint = "",
|
|
87
|
+
directory = adminWorkDirectory(),
|
|
83
88
|
}) {
|
|
84
89
|
const safeName = String(operationName).replace(/[^a-z0-9_.-]+/gi, "-");
|
|
85
90
|
const fingerprintSuffix = operationFingerprint
|
|
86
91
|
? `-${operationFingerprint}`
|
|
87
92
|
: "";
|
|
88
93
|
return path.join(
|
|
89
|
-
|
|
94
|
+
directory,
|
|
90
95
|
`lumine-admin-run-${runId}-${safeName}${fingerprintSuffix}.json`,
|
|
91
96
|
);
|
|
92
97
|
}
|
|
@@ -116,10 +121,91 @@ function resolveAutomaticCheckpointPath({
|
|
|
116
121
|
operationName: operation.name,
|
|
117
122
|
});
|
|
118
123
|
if (pathEntryExists(legacyPath)) return legacyPath;
|
|
124
|
+
// Read old OS-temp checkpoints once, then resume only from durable files.
|
|
125
|
+
// The normal scan validates the saved scope and spool hash before use.
|
|
126
|
+
for (const fingerprint of [operationFingerprint, ""]) {
|
|
127
|
+
const old = defaultCheckpointPath({
|
|
128
|
+
runId,
|
|
129
|
+
operationName: operation.name,
|
|
130
|
+
operationFingerprint: fingerprint,
|
|
131
|
+
directory: os.tmpdir(),
|
|
132
|
+
});
|
|
133
|
+
if (pathEntryExists(old)) {
|
|
134
|
+
migrateLegacyCheckpoint({ old, keyedPath, operationFingerprint });
|
|
135
|
+
return keyedPath;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
119
138
|
}
|
|
120
139
|
return keyedPath;
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
export function migrateLegacyCheckpoint({
|
|
143
|
+
old,
|
|
144
|
+
keyedPath,
|
|
145
|
+
operationFingerprint,
|
|
146
|
+
}) {
|
|
147
|
+
const lock = acquireCheckpointLock(keyedPath, operationFingerprint);
|
|
148
|
+
let copiedSpool = null;
|
|
149
|
+
let savedCheckpoint = false;
|
|
150
|
+
const assertSourceIdle = () => {
|
|
151
|
+
if (
|
|
152
|
+
(pathEntryExists(`${old}.lock`) &&
|
|
153
|
+
!checkpointLockOwnerIsDead(`${old}.lock`)) ||
|
|
154
|
+
pathEntryExists(`${old}.lock.reclaim`)
|
|
155
|
+
) {
|
|
156
|
+
throw validationError(
|
|
157
|
+
"The legacy checkpoint still has a scan lock. Finish its owner before migrating it.",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
try {
|
|
162
|
+
// A concurrent resumer may already have completed this migration.
|
|
163
|
+
if (pathEntryExists(keyedPath)) return;
|
|
164
|
+
assertSourceIdle();
|
|
165
|
+
const before = statSync(old);
|
|
166
|
+
const saved = readAdminJsonFile(old, "the legacy checkpoint", {
|
|
167
|
+
maxBytes: MAX_LEGACY_ADMIN_CHECKPOINT_BYTES,
|
|
168
|
+
});
|
|
169
|
+
if (saved.spoolPath) {
|
|
170
|
+
if (
|
|
171
|
+
!candidateSpoolBelongsToCheckpoint({
|
|
172
|
+
checkpointPath: old,
|
|
173
|
+
spoolPath: saved.spoolPath,
|
|
174
|
+
})
|
|
175
|
+
)
|
|
176
|
+
throw validationError(
|
|
177
|
+
"The legacy candidate spool does not belong to its checkpoint.",
|
|
178
|
+
);
|
|
179
|
+
copiedSpool = candidateSpoolPath(keyedPath);
|
|
180
|
+
copyFileSync(saved.spoolPath, copiedSpool, constants.COPYFILE_EXCL);
|
|
181
|
+
chmodSync(copiedSpool, 0o600);
|
|
182
|
+
saved.spoolPath = copiedSpool;
|
|
183
|
+
}
|
|
184
|
+
assertSourceIdle();
|
|
185
|
+
const after = statSync(old);
|
|
186
|
+
if (
|
|
187
|
+
before.ino !== after.ino ||
|
|
188
|
+
before.size !== after.size ||
|
|
189
|
+
before.mtimeMs !== after.mtimeMs
|
|
190
|
+
) {
|
|
191
|
+
throw validationError(
|
|
192
|
+
"The legacy checkpoint changed during migration. Retry after its owner finishes.",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
writeAdminJsonFile(keyedPath, saved, { privateFile: true });
|
|
196
|
+
savedCheckpoint = true;
|
|
197
|
+
} finally {
|
|
198
|
+
if (copiedSpool && !savedCheckpoint) {
|
|
199
|
+
try {
|
|
200
|
+
unlinkSync(copiedSpool);
|
|
201
|
+
} catch {
|
|
202
|
+
/* preserve the original migration error */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
releaseCheckpointLock(lock);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
123
209
|
function pathEntryExists(filePath) {
|
|
124
210
|
try {
|
|
125
211
|
lstatSync(filePath);
|
|
@@ -209,27 +295,7 @@ function removeDeadCheckpointLock(lockPath) {
|
|
|
209
295
|
// replacement lock created after another process removed the stale one.
|
|
210
296
|
linkSync(lockPath, reclaimPath);
|
|
211
297
|
ownsReclaim = true;
|
|
212
|
-
|
|
213
|
-
if (!fileState.isFile() || fileState.isSymbolicLink()) return false;
|
|
214
|
-
if (fileState.size > 16 * 1024) return false;
|
|
215
|
-
const state = JSON.parse(readFileSync(reclaimPath, "utf8"));
|
|
216
|
-
if (
|
|
217
|
-
state?.kind !== "admin-pagination-lock" ||
|
|
218
|
-
!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(
|
|
219
|
-
String(state?.token || ""),
|
|
220
|
-
) ||
|
|
221
|
-
!/^[a-f0-9]{64}$/i.test(String(state?.operationFingerprint || ""))
|
|
222
|
-
) {
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
225
|
-
const pid = Number(state?.pid);
|
|
226
|
-
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
227
|
-
try {
|
|
228
|
-
process.kill(pid, 0);
|
|
229
|
-
return false;
|
|
230
|
-
} catch (error) {
|
|
231
|
-
if (error?.code !== "ESRCH") return false;
|
|
232
|
-
}
|
|
298
|
+
if (!checkpointLockOwnerIsDead(reclaimPath)) return false;
|
|
233
299
|
const claimedState = lstatSync(reclaimPath);
|
|
234
300
|
const currentState = lstatSync(lockPath);
|
|
235
301
|
if (
|
|
@@ -253,6 +319,34 @@ function removeDeadCheckpointLock(lockPath) {
|
|
|
253
319
|
}
|
|
254
320
|
}
|
|
255
321
|
|
|
322
|
+
// Read-only liveness proof also permits migration from a crashed legacy owner
|
|
323
|
+
// without editing its old temporary files. Unknown/live owners still stop it.
|
|
324
|
+
function checkpointLockOwnerIsDead(lockPath) {
|
|
325
|
+
try {
|
|
326
|
+
const fileState = lstatSync(lockPath);
|
|
327
|
+
if (!fileState.isFile() || fileState.isSymbolicLink() || fileState.size > 16 * 1024)
|
|
328
|
+
return false;
|
|
329
|
+
const state = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
330
|
+
if (
|
|
331
|
+
state?.kind !== "admin-pagination-lock" ||
|
|
332
|
+
!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(
|
|
333
|
+
String(state?.token || ""),
|
|
334
|
+
) ||
|
|
335
|
+
!/^[a-f0-9]{64}$/i.test(String(state?.operationFingerprint || ""))
|
|
336
|
+
) return false;
|
|
337
|
+
const pid = Number(state.pid);
|
|
338
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
339
|
+
try {
|
|
340
|
+
process.kill(pid, 0);
|
|
341
|
+
return false;
|
|
342
|
+
} catch (error) {
|
|
343
|
+
return error?.code === "ESRCH";
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
256
350
|
export function releaseCheckpointLock(lock) {
|
|
257
351
|
try {
|
|
258
352
|
const fileState = lstatSync(lock.lockPath);
|
package/lib/admin.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { assertAuthScope, resolveAuth } from "./auth.js";
|
|
14
14
|
import { requestJson } from "./http.js";
|
|
15
|
+
import { prepareAdminMutationIntent } from "./admin-request-intents.js";
|
|
15
16
|
import { formatBytes, parseStorageSizeBytes } from "./util.js";
|
|
16
17
|
import {
|
|
17
18
|
extractNewsClaim,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
import { runAdminRuntimeLogWorkflow } from "./admin-runtime-logs.js";
|
|
35
36
|
import {
|
|
36
37
|
dailyRunGather,
|
|
38
|
+
dailyRunProgress,
|
|
37
39
|
featuredCandidates,
|
|
38
40
|
reportServe,
|
|
39
41
|
} from "./admin-daily.js";
|
|
@@ -591,11 +593,13 @@ export async function adminCommand(options) {
|
|
|
591
593
|
const composite =
|
|
592
594
|
namespace === "daily-run" && action === "gather"
|
|
593
595
|
? dailyRunGather
|
|
594
|
-
: namespace === "
|
|
595
|
-
?
|
|
596
|
-
: namespace === "
|
|
597
|
-
?
|
|
598
|
-
:
|
|
596
|
+
: namespace === "daily-run" && action === "progress"
|
|
597
|
+
? dailyRunProgress
|
|
598
|
+
: namespace === "featured" && action === "candidates"
|
|
599
|
+
? featuredCandidates
|
|
600
|
+
: namespace === "report" && action === "serve"
|
|
601
|
+
? reportServe
|
|
602
|
+
: null;
|
|
599
603
|
if (composite) {
|
|
600
604
|
const result = await composite(options);
|
|
601
605
|
if (options.adminOutput) {
|
|
@@ -631,7 +635,7 @@ export async function adminCommand(options) {
|
|
|
631
635
|
? parseRecommendationContentTypes(options.adminContentTypes)
|
|
632
636
|
: null;
|
|
633
637
|
const auth = await resolveAuth(options);
|
|
634
|
-
await assertAuthScope({
|
|
638
|
+
const authSession = await assertAuthScope({
|
|
635
639
|
options,
|
|
636
640
|
auth,
|
|
637
641
|
scope: operation.mutates ? "build:write" : "build:read",
|
|
@@ -755,12 +759,31 @@ export async function adminCommand(options) {
|
|
|
755
759
|
}
|
|
756
760
|
}
|
|
757
761
|
}
|
|
762
|
+
const mutationIntent =
|
|
763
|
+
operation.mutates &&
|
|
764
|
+
!operation.featuredWorkflow &&
|
|
765
|
+
operation.name !== "post.skip-batch" &&
|
|
766
|
+
!operation.name.startsWith("runtime-logs.")
|
|
767
|
+
? prepareAdminMutationIntent({
|
|
768
|
+
operation,
|
|
769
|
+
apiUrl: options.apiUrl,
|
|
770
|
+
authority: {
|
|
771
|
+
account: canonicalAdminAccount(authSession),
|
|
772
|
+
runId,
|
|
773
|
+
correctionSessionId,
|
|
774
|
+
commentSessionId,
|
|
775
|
+
},
|
|
776
|
+
idempotencyKey: options.idempotencyKey,
|
|
777
|
+
})
|
|
778
|
+
: null;
|
|
758
779
|
const requestId =
|
|
759
|
-
|
|
780
|
+
mutationIntent?.requestId ||
|
|
781
|
+
options.idempotencyKey ||
|
|
782
|
+
(operation.mutates ? `cli:${randomUUID()}` : "");
|
|
760
783
|
let result;
|
|
761
784
|
try {
|
|
762
|
-
const fetchOperation = async (requestPath = operation.path, signal) =>
|
|
763
|
-
requestJson({
|
|
785
|
+
const fetchOperation = async (requestPath = operation.path, signal) => {
|
|
786
|
+
const receipt = await requestJson({
|
|
764
787
|
method: operation.method,
|
|
765
788
|
url: `${options.apiUrl}${requestPath}`,
|
|
766
789
|
authToken: auth.token,
|
|
@@ -784,6 +807,18 @@ export async function adminCommand(options) {
|
|
|
784
807
|
timeoutMs: operation.timeoutMs || options.timeoutMs,
|
|
785
808
|
signal,
|
|
786
809
|
});
|
|
810
|
+
if (
|
|
811
|
+
typeof receipt.ok !== "boolean" ||
|
|
812
|
+
typeof receipt.status !== "string"
|
|
813
|
+
) {
|
|
814
|
+
const error = new Error(
|
|
815
|
+
"The API did not return a valid admin receipt. The request outcome is unconfirmed.",
|
|
816
|
+
);
|
|
817
|
+
error.code = "CLI_ADMIN_RECEIPT_INVALID";
|
|
818
|
+
throw error;
|
|
819
|
+
}
|
|
820
|
+
return receipt;
|
|
821
|
+
};
|
|
787
822
|
const transformResult = (rawResult) =>
|
|
788
823
|
transformAdminResult({
|
|
789
824
|
operation,
|
|
@@ -859,6 +894,7 @@ export async function adminCommand(options) {
|
|
|
859
894
|
}
|
|
860
895
|
result = transformResult(await fetchOperation());
|
|
861
896
|
}
|
|
897
|
+
mutationIntent?.confirm(result);
|
|
862
898
|
} catch (error) {
|
|
863
899
|
if (operation.name === "runtime.evidence" && error.status === 404) {
|
|
864
900
|
error.code = "CLI_ADMIN_RUNTIME_EVIDENCE_NOT_DEPLOYED";
|
|
@@ -1003,7 +1039,19 @@ export async function adminCommand(options) {
|
|
|
1003
1039
|
data: { ...(result.data || {}), artifacts },
|
|
1004
1040
|
};
|
|
1005
1041
|
}
|
|
1006
|
-
|
|
1042
|
+
const output = await finishAdminOutput({ options, operation, result });
|
|
1043
|
+
mutationIntent?.delivered();
|
|
1044
|
+
return output;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function canonicalAdminAccount(session) {
|
|
1048
|
+
const userId = Number(session?.userId);
|
|
1049
|
+
if (!Number.isSafeInteger(userId) || userId <= 0) {
|
|
1050
|
+
throw cliValidationError(
|
|
1051
|
+
"The API did not confirm the signed-in account. The mutation was not sent.",
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
return userId;
|
|
1007
1055
|
}
|
|
1008
1056
|
|
|
1009
1057
|
function transformAdminResult({
|
package/lib/auth.js
CHANGED
package/lib/build-review.js
CHANGED
|
@@ -8,11 +8,11 @@ import {
|
|
|
8
8
|
statSync,
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from "node:fs";
|
|
11
|
-
import os from "node:os";
|
|
12
11
|
import path from "node:path";
|
|
13
12
|
|
|
14
13
|
import { requestJson } from "./http.js";
|
|
15
14
|
import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
|
|
15
|
+
import { adminWorkDirectory } from "./admin-work-directory.js";
|
|
16
16
|
|
|
17
17
|
const DEFAULT_REVIEW_WAIT_MS = 10_000;
|
|
18
18
|
const MAX_REVIEW_WAIT_MS = 45_000;
|
|
@@ -961,10 +961,10 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
|
|
|
961
961
|
outputDir = mkdtempSync(path.join(outputRoot, `build-${buildId}-review-`));
|
|
962
962
|
} else {
|
|
963
963
|
outputDir = mkdtempSync(
|
|
964
|
-
path.join(
|
|
964
|
+
path.join(adminWorkDirectory(), `build-${buildId}-review-`),
|
|
965
965
|
);
|
|
966
966
|
}
|
|
967
|
-
const profileDir = mkdtempSync(path.join(
|
|
967
|
+
const profileDir = mkdtempSync(path.join(outputDir, "browser-profile-"));
|
|
968
968
|
const screenshotPath = path.join(outputDir, "runtime.png");
|
|
969
969
|
const receiptPath = path.join(outputDir, "review.json");
|
|
970
970
|
const appUrl = `${String(options.siteUrl).replace(/\/$/, "")}/app/${buildId}`;
|
package/lib/commands.js
CHANGED
|
@@ -2503,6 +2503,7 @@ export function parseArgs(args) {
|
|
|
2503
2503
|
adminCheckpoint: raw.checkpoint ? String(raw.checkpoint) : "",
|
|
2504
2504
|
adminOutput: raw.output ? String(raw.output) : "",
|
|
2505
2505
|
adminOutputDir: raw.outputDir ? String(raw.outputDir) : "",
|
|
2506
|
+
adminDir: raw.dir ? String(raw.dir) : "",
|
|
2506
2507
|
adminClaimFile: raw.claim ? String(raw.claim) : "",
|
|
2507
2508
|
adminScaffoldFile: raw.scaffold ? String(raw.scaffold) : "",
|
|
2508
2509
|
adminTargetFile: raw.targetFile ? String(raw.targetFile) : "",
|
|
@@ -3032,6 +3033,7 @@ export function printHelp() {
|
|
|
3032
3033
|
lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
|
|
3033
3034
|
lumine admin builds candidates [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
|
|
3034
3035
|
lumine admin daily-run gather --output-dir <dir> [--date YYYY-MM-DD] [--json] (all read-only daily reports + bot-output pages + bot-chats.txt)
|
|
3036
|
+
lumine admin daily-run progress --dir <dir> [--file <duty-evidence.json>] [--json] (reading, canonical effort counts, pending duties)
|
|
3035
3037
|
lumine admin featured candidates [--days 7] [--after <iso>] [--json] (recent, provably never-Featured Subjects with comment counts)
|
|
3036
3038
|
lumine admin report serve --file <report.md> [--port <n>] [--open] [--json] (loopback report page with section nav)
|
|
3037
3039
|
lumine admin builds review <build-url-or-id> [--output-dir <dir>] [--wait-ms <ms>] [--interact <steps.json>] [--browser-path <path>] [--json]
|
package/lib/http.js
CHANGED
|
@@ -18,7 +18,7 @@ export async function requestJson({
|
|
|
18
18
|
headers,
|
|
19
19
|
signal,
|
|
20
20
|
});
|
|
21
|
-
|
|
21
|
+
let data = parseJson(text);
|
|
22
22
|
if (!response.ok) {
|
|
23
23
|
const error = new Error(
|
|
24
24
|
data?.error?.message ||
|
|
@@ -30,7 +30,22 @@ export async function requestJson({
|
|
|
30
30
|
error.data = data;
|
|
31
31
|
throw error;
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
// parseJson intentionally wraps non-JSON error text for HTTP failures.
|
|
34
|
+
// A successful response must contain actual JSON, never that fallback.
|
|
35
|
+
try {
|
|
36
|
+
data = JSON.parse(text);
|
|
37
|
+
} catch {
|
|
38
|
+
data = null;
|
|
39
|
+
}
|
|
40
|
+
if (!data || typeof data !== "object") {
|
|
41
|
+
const error = new Error(
|
|
42
|
+
"The API returned no valid JSON response. The request outcome is unconfirmed.",
|
|
43
|
+
);
|
|
44
|
+
error.code = "lumine_http_invalid_response";
|
|
45
|
+
error.status = response.status;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
return data;
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
export async function probeUrl({ url, authToken, timeoutMs, signal }) {
|
package/lib/sdk.js
CHANGED
|
@@ -214,6 +214,16 @@ export const SDK_CLI_METHODS = {
|
|
|
214
214
|
scopes: ["rewards:claim"], readOnly: true,
|
|
215
215
|
mapArgs: (args) => ({ ruleId: args.ruleId, cursor: args.cursor, limit: args.limit }),
|
|
216
216
|
},
|
|
217
|
+
"rewards.interpretAnswer": {
|
|
218
|
+
path: "api/rewards/interpret-answer", special: "rewards", operation: "interpret-answer",
|
|
219
|
+
scopes: ["rewards:claim"], readOnly: true,
|
|
220
|
+
mapArgs: (args) => ({ challengeId: args.challengeId, text: args.text }),
|
|
221
|
+
},
|
|
222
|
+
"rewards.getChallengeBoard": {
|
|
223
|
+
path: "api/rewards/challenge-board", special: "rewards", operation: "challenge-board",
|
|
224
|
+
scopes: ["rewards:claim"], readOnly: true,
|
|
225
|
+
mapArgs: () => ({}),
|
|
226
|
+
},
|
|
217
227
|
"rewards.getArchivedProblem": {
|
|
218
228
|
path: "api/rewards/archived-problem", special: "rewards", operation: "archived-problem",
|
|
219
229
|
scopes: ["rewards:claim"], readOnly: true,
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Version: 1.63.0
|
|
4
4
|
Updated: 2026-09-28
|
|
5
|
-
Generated: 2026-
|
|
5
|
+
Generated: 2026-10-03T04:47:01.454Z
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -81,8 +81,9 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
|
|
|
81
81
|
|
|
82
82
|
### Twinkle.viewer
|
|
83
83
|
- async get() | scopes: none
|
|
84
|
-
- Returns: { id, username, profilePicUrl, isLoggedIn, isOwner, isGuest }
|
|
84
|
+
- Returns: { id, username, profilePicUrl, isLoggedIn, isOwner, isGuest, ageTier }
|
|
85
85
|
- Cached; use refresh() to re-fetch.
|
|
86
|
+
- ageTier is 'kid', 'teen' or 'adult', taken from the viewer's Teenager and Adult achievements (granted once an admin approves their birthdate). Guests and viewers without either achievement are 'kid'. It is a display and default-selection hint computed by the Twinkle page; do not treat it as a server-side permission.
|
|
86
87
|
- async refresh() | scopes: none
|
|
87
88
|
- Returns: Viewer info
|
|
88
89
|
- Forces a fresh fetch from the parent.
|
|
@@ -1515,6 +1516,21 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1515
1516
|
- Standings of who earned the most XP or Coins in THIS app, computed by Twinkle from its own receipts (never from anything the app submits). period 'day' is today (site day, UTC), 'week' the last 7 site days, 'all' (default) every day since approval. limit defaults to 20, max 100.
|
|
1516
1517
|
- available says which boards this app's approved rules can pay: show a Coins board only when available.coins is true (an app whose rules pay XP only has no Coins standings). me is the signed-in viewer's own standing even when they fall outside the page, or null when they earned nothing in the period.
|
|
1517
1518
|
- Drafts and previews return mode 'preview' with no entries. Use Twinkle.leaderboards for app-defined scores; use this for real XP and Coins earned.
|
|
1519
|
+
- await Twinkle.rewards.interpretAnswer({ challengeId, text }) | scopes: rewards:claim
|
|
1520
|
+
- Returns: { mode: "live" | "preview", understood: boolean, value: number | null, source: "plain" | "model" }
|
|
1521
|
+
- Read a learner's free-form answer text into the single number they meant, so they can answer in any format.
|
|
1522
|
+
- For numeric-quiz bounties. Call it only when the typed text is not already a plain number or fraction (check that locally); a plain number returns source 'plain' with no model call. Then pass the returned value to claim({ challengeId, answers: [value] }). A small model reads the text and the platform pays for it, so the learner's AI Energy is never charged.
|
|
1523
|
+
- understood: false means the text had no single clear number (several alternatives, no number, or a calculation). Tell the learner in friendly words to type just the number. This is not a wrong answer and does not use a try.
|
|
1524
|
+
- The reader is given the puzzle text only to learn the requested answer format (unit, fraction/decimal/percent, which of several numbers answers the question), never the answer key, and never calculates or converts units: it only reads what was written, and the number it returns must be one the learner actually typed. Typed expressions like 5+8 are intentionally not evaluated.
|
|
1525
|
+
- Requires an open challenge of the learner's own (build_reward_challenge_not_open, HTTP 409 otherwise). Limit: 120 characters (build_reward_answer_text_invalid, 400), rate limited to a burst of 4 then one read every 5 seconds (build_reward_throttled, 429). build_reward_answer_reader_unavailable (503) means the reader is down: ask the learner to type just the number.
|
|
1526
|
+
- Drafts and owner previews return mode 'preview' and need no open challenge.
|
|
1527
|
+
- await Twinkle.rewards.getChallengeBoard() | scopes: rewards:claim
|
|
1528
|
+
- Returns: { mode: "live", dayKey, puzzles: [{ ruleId, title, setKey, rating, solvers, failers, failures, daysStanding, status: "untouched" | "undefeated" | "defeated" }], topSolvers: [{ rank, userId, username, profilePicUrl, rating, solves, hardestSolve: { ruleId, setKey, rating } | null }], recentSolves: [{ id, userId, username, ruleId, setKey, tier: "solved" | "first-solve" | "hard-won" | "legendary", attempt, firstSolve, failersBefore, failuresBefore, daysStanding, puzzleRating, at }], me: { rating, solves, rank } | null } | { mode: "preview", puzzles: [], topSolvers: [], recentSolves: [], me: null, message }
|
|
1529
|
+
- Front-page difficulty board for until-earned quiz bounties: what is being tried and failed, who is rated highest, and the latest defeats.
|
|
1530
|
+
- Computed by Twinkle from its own receipts and attempts, never from anything the app submits. Puzzles are Elo-rated against players: a player's first miss on a puzzle is a win for the puzzle, a solve is a win for the player, and repeat misses by one player never stack.
|
|
1531
|
+
- puzzles lists today's set for each until-earned rule. status 'undefeated' means someone tried and nobody has solved it yet; show it as a standing challenge. failers is how many different players missed it; failures is total wrong answers.
|
|
1532
|
+
- tier on a recent solve says how loudly to celebrate: 'legendary' (first solve of a long-undefeated puzzle), 'hard-won', 'first-solve', or plain 'solved'. It is fixed at solve time, so it never changes later.
|
|
1533
|
+
- No questions, answers, guides or private work are ever returned. Names are the same public names the XP leaderboard shows; other players who missed a puzzle are counted, never named. Drafts and previews return mode 'preview' with empty lists.
|
|
1518
1534
|
- await Twinkle.rewards.getTimeline({ ruleId?, cursor?, limit? } = {}) | scopes: rewards:claim
|
|
1519
1535
|
- Returns: { mode: "live", dayKey, entries: [{ receiptId, ruleId, ruleTitle, setKey, title, promptPreview, dayKey, closedAt, solvedAt, solver: { userId, username }, firstSolver, xp, coins, attempt }], nextCursor } | { mode: "preview", entries: [], nextCursor: null, message }
|
|
1520
1536
|
- Browse confirmed solves of retired until-earned quiz bounties, newest first.
|
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -1022,14 +1022,50 @@ each swap again. Never omit this section from a full-run report.
|
|
|
1022
1022
|
|
|
1023
1023
|
```bash
|
|
1024
1024
|
lumine admin daily-run gather --output-dir <dir> [--date YYYY-MM-DD] --json
|
|
1025
|
+
lumine admin daily-run progress --dir <dir> [--file <duty-evidence.json>] --json
|
|
1025
1026
|
lumine admin featured candidates [--days 7] [--after <iso>] --json
|
|
1026
1027
|
lumine admin report serve --file <dir>/daily-management-report.md [--port <n>] [--open] --json
|
|
1027
1028
|
```
|
|
1028
1029
|
|
|
1029
1030
|
These compose the documented commands in this contract, running them as child processes, so every scope check, audit and pagination rule applies unchanged.
|
|
1030
|
-
- **`gather`** saves each read-only report into `<dir>`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. It needs the active run for run-scoped reads.
|
|
1031
|
-
- **`
|
|
1032
|
-
- **`
|
|
1031
|
+
- **`gather`** saves each read-only report into `<dir>`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. It also creates `review-state.json`: every source is fetched, not read. Children bypass update checks, require matching fresh stdout/file receipts, and retry an unconfirmed opted-in read at most once. Mutations never auto-retry. It needs the active run for run-scoped reads.
|
|
1032
|
+
- **`progress`** reads local review state and explicit operator acknowledgments, writes private `progress.json`, and keeps reading, canonical effort mutations, the run lease, and browser checks separate. Add all required duties for the actual run scope and its carryovers; collection alone is not a full review. A completed run lease cannot complete a pending duty. Reading acknowledgments bind to the current file bytes and are operator attestations, not proof of comprehension.
|
|
1033
|
+
- **`featured candidates`** returns Subjects posted after the Bangkok-midnight cutoff that are off the board and provably never Featured. Each one comes with its human/bot comment counts, explicit comment truncation/completeness, and whether Mikey has viewed it. Counts from truncated context are partial. An unavailable Subject context fails the collection. Eligibility only: ranking stays editorial.
|
|
1034
|
+
- **`report serve`** detaches a loopback server that answers only `/`, `marked.min.js` and the report Markdown. `--open` opens Chrome and reports whether the OS accepted the request. Render, navigation and original-source verification remain pending until actually checked. Startup failures stop only the child this command started.
|
|
1035
|
+
|
|
1036
|
+
### Durable recovery and explicit duty evidence (CLI 0.3.5)
|
|
1037
|
+
|
|
1038
|
+
Default checkpoints, mutation intents, runtime-log evidence and Build reviews live
|
|
1039
|
+
under `work/lumine-admin` in the invocation directory, with private permissions.
|
|
1040
|
+
Explicit persistent output/checkpoint paths take priority. Older automatic
|
|
1041
|
+
checkpoints in OS temp storage are copied with their candidate spools to persistent
|
|
1042
|
+
storage before resuming; the existing scope/hash checks still apply.
|
|
1043
|
+
|
|
1044
|
+
Generic mutations persist their exact operation/account/run/session key before
|
|
1045
|
+
sending. A missing receipt leaves that key pending; rerunning the exact command
|
|
1046
|
+
reuses it instead of issuing a fresh mutation. Changed bodies or authorities get
|
|
1047
|
+
different keys. A canonical receipt is archived privately before printing, and
|
|
1048
|
+
the pending pointer closes only after successful output. Existing Featured,
|
|
1049
|
+
batch-skip and runtime-log workflows retain their own recovery contracts.
|
|
1050
|
+
|
|
1051
|
+
`duty-evidence.json` is local operator evidence, for example:
|
|
1052
|
+
|
|
1053
|
+
```json
|
|
1054
|
+
{
|
|
1055
|
+
"protocol": 1,
|
|
1056
|
+
"runId": 107,
|
|
1057
|
+
"runReport": { "file": "run-report.json", "bytes": 123, "sha256": "<full digest>" },
|
|
1058
|
+
"requiredDuties": ["effort-levels", "report-browser", "football-coach"],
|
|
1059
|
+
"reading": [{ "file": "bot-chats.txt", "bytes": 456, "sha256": "<full digest>", "complete": true, "readAt": "2026-10-01T00:00:00Z" }],
|
|
1060
|
+
"duties": [{ "id": "effort-levels", "status": "completed", "evidence": [{ "file": "run-report.json", "bytes": 123, "sha256": "<full digest>" }], "note": "All candidates considered; preserve higher-authority levels." }]
|
|
1061
|
+
}
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
Use actual byte counts/digests and only acknowledge work performed. Every evidence
|
|
1065
|
+
reference stays inside the run directory. Changed files invalidate reading
|
|
1066
|
+
acknowledgments. Effort totals use the canonical report's `subject.effort.set`
|
|
1067
|
+
audit counts, including failed attempts separately; unavailable receipts remain
|
|
1068
|
+
unknown. Pending browser duties need genuine version-bound interaction evidence.
|
|
1033
1069
|
|
|
1034
1070
|
### Full management report in Chrome
|
|
1035
1071
|
|
|
@@ -1039,7 +1075,7 @@ Do this as part of finishing the authorized run; a Markdown path in chat alone
|
|
|
1039
1075
|
is insufficient, and no additional confirmation is needed to open the report.
|
|
1040
1076
|
|
|
1041
1077
|
1. Save the complete report as
|
|
1042
|
-
`/
|
|
1078
|
+
`/Users/mikey/Developer/twinkle/work/daily-management-YYYY-MM-DD/daily-management-report.md`, using the
|
|
1043
1079
|
run's Bangkok date. Include every required reporting section, coverage gap,
|
|
1044
1080
|
pending decision and carryover. Reflect later owner decisions accurately.
|
|
1045
1081
|
2. Render the entire Markdown into a readable HTML page with section links,
|