@lingjingai/scriptctl 0.11.5 → 0.12.0
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/dist/cli.js +6 -10
- package/dist/cli.js.map +1 -1
- package/dist/common.d.ts +1 -1
- package/dist/common.js +1 -4
- package/dist/common.js.map +1 -1
- package/dist/domain/direct-core.d.ts +0 -2
- package/dist/domain/direct-core.js +0 -24
- package/dist/domain/direct-core.js.map +1 -1
- package/dist/help-text.js +0 -3
- package/dist/help-text.js.map +1 -1
- package/dist/usecases/direct.d.ts +0 -4
- package/dist/usecases/direct.js +185 -490
- package/dist/usecases/direct.js.map +1 -1
- package/dist/usecases/script.js +3 -25
- package/dist/usecases/script.js.map +1 -1
- package/package.json +1 -1
package/dist/usecases/direct.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_CONCURRENCY, DEFAULT_MODEL, DEFAULT_PROVIDER,
|
|
4
|
-
import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan,
|
|
3
|
+
import { CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_CONCURRENCY, DEFAULT_MODEL, DEFAULT_PROVIDER, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, REVIEW_TARGETS, SUPPORTED_EXTS, deletePath, directDir, exists, fmtId, readJson, readText, writeJson, } from "../common.js";
|
|
4
|
+
import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, enrichEpisodePlanTitles, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, normalizeInt, uniqueAdd, validateEpisodeExtractionQuality, _md_push_asset, curateScriptAssets, applyMetadataToScript, } from "../domain/direct-core.js";
|
|
5
5
|
import { validateScript } from "../domain/script-core.js";
|
|
6
6
|
import { makeProvider } from "../infra/providers.js";
|
|
7
7
|
import { makeSourceManifest, prepareSource, } from "../infra/converters.js";
|
|
@@ -23,6 +23,12 @@ function checkpointTimestamp() {
|
|
|
23
23
|
const now = new Date();
|
|
24
24
|
return now.toISOString().replace(/\.\d+Z$/, "Z");
|
|
25
25
|
}
|
|
26
|
+
function parseIntList(v) {
|
|
27
|
+
return strOf(v).split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !Number.isNaN(n));
|
|
28
|
+
}
|
|
29
|
+
function parseStrList(v) {
|
|
30
|
+
return strOf(v).split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
31
|
+
}
|
|
26
32
|
// ---------------------------------------------------------------------------
|
|
27
33
|
// run_state.json
|
|
28
34
|
// ---------------------------------------------------------------------------
|
|
@@ -163,54 +169,32 @@ function persistBatchResult(dir, batch, result) {
|
|
|
163
169
|
deletePath(mdPath);
|
|
164
170
|
}
|
|
165
171
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Per-batch result files ARE the state. A batch with `<key>.json` is done; with
|
|
174
|
+
// `<key>.error.json` it failed; with neither it is pending. Reuse = file
|
|
175
|
+
// presence — the agent drives what to (re)run via `direct status` + --episodes/
|
|
176
|
+
// --batches selection. No content hashes, no global checkpoint, no destructive
|
|
177
|
+
// reset: re-running a unit only ever touches that unit's own files, so the other
|
|
178
|
+
// episodes can never be lost.
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Read a persisted batch result (reused or freshly written) for assembly.
|
|
181
|
+
// Returns null if absent or unreadable, in which case the batch counts as
|
|
182
|
+
// not-done and will be (re)run.
|
|
183
|
+
function readBatchResult(dir, batch) {
|
|
184
|
+
const p = batchResultPath(dir, batch);
|
|
185
|
+
if (!exists(p))
|
|
174
186
|
return null;
|
|
175
187
|
try {
|
|
176
|
-
const
|
|
177
|
-
|
|
188
|
+
const result = normalizeEpisodeResult(readJson(p), batch);
|
|
189
|
+
result["_batch_id"] = batchResultKey(batch);
|
|
190
|
+
result["_batch_part"] = Number(batch["part"]);
|
|
191
|
+
result["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
|
|
192
|
+
return result;
|
|
178
193
|
}
|
|
179
194
|
catch {
|
|
180
195
|
return null;
|
|
181
196
|
}
|
|
182
197
|
}
|
|
183
|
-
function writeUnitMeta(metaPath, meta) {
|
|
184
|
-
fs.mkdirSync(path.dirname(metaPath), { recursive: true });
|
|
185
|
-
writeJson(metaPath, meta);
|
|
186
|
-
}
|
|
187
|
-
function removeUnitMeta(metaPath) {
|
|
188
|
-
if (exists(metaPath))
|
|
189
|
-
deletePath(metaPath);
|
|
190
|
-
}
|
|
191
|
-
function stampEpisodeMeta(dir, ep, inputHash, provenance, providerName, model) {
|
|
192
|
-
writeUnitMeta(episodeMetaPath(dir, ep), {
|
|
193
|
-
schema: 1, key: episodeResultKey(ep), episode: Number(ep["episode"]),
|
|
194
|
-
input_hash: inputHash, provenance, status: "ok", provider: providerName, model, extracted_at: checkpointTimestamp(),
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
function stampBatchMeta(dir, batch, inputHash, provenance, providerName, model) {
|
|
198
|
-
writeUnitMeta(batchMetaPath(dir, batch), {
|
|
199
|
-
schema: 1, key: batchResultKey(batch), episode: Number(batch["episode"]), part: Number(batch["part"]),
|
|
200
|
-
input_hash: inputHash, provenance, status: "ok", provider: providerName, model, extracted_at: checkpointTimestamp(),
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
// Content-address a single episode/batch plan unit: the contract version, the
|
|
204
|
-
// exact source span text, the title-stable plan item, and provider/model. Any
|
|
205
|
-
// change to what would alter extraction rotates the hash for THAT unit only.
|
|
206
|
-
export function computeUnitHash(sourceText, unit, providerName, model) {
|
|
207
|
-
const span = isDict(unit["source_span"]) ? unit["source_span"] : {};
|
|
208
|
-
const start = Number(span["start"] ?? 0);
|
|
209
|
-
const end = Number(span["end"] ?? 0);
|
|
210
|
-
const spanText = sourceText.slice(start, end);
|
|
211
|
-
const planText = JSON.stringify(unit, checkpointReplacer());
|
|
212
|
-
return sha256Text([String(DIRECT_CONTRACT_VERSION), spanText, planText, providerName ?? "", model ?? ""].join("\u0000"));
|
|
213
|
-
}
|
|
214
198
|
// Delete result/meta/error/markdown files whose unit key is no longer in the
|
|
215
199
|
// current plan (e.g. the source shed an episode). Pure function of the plan —
|
|
216
200
|
// it never inspects hashes, content, or run_state, so it can only remove units
|
|
@@ -235,69 +219,6 @@ function gcOrphanUnits(dir, liveKeys) {
|
|
|
235
219
|
}
|
|
236
220
|
return removed;
|
|
237
221
|
}
|
|
238
|
-
// Title fields are LLM-mutated downstream by enrichEpisodePlanTitles, so they
|
|
239
|
-
// must be excluded from unit hashes — otherwise every rerun gets a fresh SHA,
|
|
240
|
-
// the cached unit never matches, and that unit re-extracts from scratch.
|
|
241
|
-
const CHECKPOINT_UNSTABLE_KEYS = new Set(["title", "generated_title", "title_status", "title_source"]);
|
|
242
|
-
function checkpointReplacer() {
|
|
243
|
-
// Python's json.dumps(sort_keys=True) sorts keys recursively. Replicate by walking and sorting.
|
|
244
|
-
return function (key, value) {
|
|
245
|
-
if (CHECKPOINT_UNSTABLE_KEYS.has(key))
|
|
246
|
-
return undefined;
|
|
247
|
-
if (isDict(value)) {
|
|
248
|
-
const sorted = {};
|
|
249
|
-
for (const k of Object.keys(value).sort()) {
|
|
250
|
-
if (CHECKPOINT_UNSTABLE_KEYS.has(k))
|
|
251
|
-
continue;
|
|
252
|
-
sorted[k] = value[k];
|
|
253
|
-
}
|
|
254
|
-
return sorted;
|
|
255
|
-
}
|
|
256
|
-
return value;
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
// Non-destructive reuse: a cached episode result is reusable iff its sidecar
|
|
260
|
-
// meta records the same input_hash we compute for the current plan unit. The
|
|
261
|
-
// hash subsumes the old provider / source_span / episode-number / contract
|
|
262
|
-
// checks — any of those changing rotates the hash. On any mismatch or read
|
|
263
|
-
// failure we return null and let the caller re-extract and overwrite; we NEVER
|
|
264
|
-
// delete the cached file pre-emptively (that was the data-loss root cause).
|
|
265
|
-
export function loadCachedEpisode(sourceText, episodeResultsDir, ep, expectedHash) {
|
|
266
|
-
const meta = readUnitMeta(episodeMetaPath(episodeResultsDir, ep));
|
|
267
|
-
if (!meta || meta["input_hash"] !== expectedHash)
|
|
268
|
-
return null;
|
|
269
|
-
if (meta["status"] === "terminal")
|
|
270
|
-
return null;
|
|
271
|
-
const p = episodeResultPath(episodeResultsDir, ep);
|
|
272
|
-
if (!exists(p))
|
|
273
|
-
return null;
|
|
274
|
-
try {
|
|
275
|
-
const result = normalizeEpisodeResult(readJson(p), ep);
|
|
276
|
-
validateEpisodeExtractionQuality(sourceText, ep, result);
|
|
277
|
-
return result;
|
|
278
|
-
}
|
|
279
|
-
catch {
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
export function loadCachedBatch(sourceText, batchResultsDir, batch, expectedHash) {
|
|
284
|
-
const meta = readUnitMeta(batchMetaPath(batchResultsDir, batch));
|
|
285
|
-
if (!meta || meta["input_hash"] !== expectedHash)
|
|
286
|
-
return null;
|
|
287
|
-
if (meta["status"] === "terminal")
|
|
288
|
-
return null;
|
|
289
|
-
const p = batchResultPath(batchResultsDir, batch);
|
|
290
|
-
if (!exists(p))
|
|
291
|
-
return null;
|
|
292
|
-
try {
|
|
293
|
-
const result = normalizeEpisodeResult(readJson(p), batch);
|
|
294
|
-
validateBatchExtractionQuality(sourceText, batch, result);
|
|
295
|
-
return result;
|
|
296
|
-
}
|
|
297
|
-
catch {
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
222
|
function mergeScene(target, source) {
|
|
302
223
|
if ((target["location_name"] === "" || target["location_name"] === "未知场景" || target["location_name"] === null || target["location_name"] === undefined) &&
|
|
303
224
|
source["location_name"]) {
|
|
@@ -389,9 +310,12 @@ async function providerExtractAssetCurationLocal(provider, sourceText, script) {
|
|
|
389
310
|
}
|
|
390
311
|
return {};
|
|
391
312
|
}
|
|
392
|
-
|
|
313
|
+
// Record a batch failure: write `<key>.error.json` (which marks the batch as
|
|
314
|
+
// "tried and failed" → sticky on plain resume) and drop any stale result file.
|
|
315
|
+
// The error_code (e.g. PROVIDER_CONTENT_FILTERED) is surfaced so the agent can
|
|
316
|
+
// see WHY in `direct status` / `direct inspect` and decide whether to re-run.
|
|
317
|
+
function writeBatchFailure(dir, batch, exc) {
|
|
393
318
|
const err = exc;
|
|
394
|
-
const terminal = classifyProviderError(exc) === "terminal";
|
|
395
319
|
const error = {
|
|
396
320
|
batch_id: batchResultKey(batch),
|
|
397
321
|
episode: Number(batch["episode"]),
|
|
@@ -400,11 +324,11 @@ function writeBatchFailure(dir, batch, exc, inputHash, providerName, model) {
|
|
|
400
324
|
line_range: batch["line_range"],
|
|
401
325
|
error_type: err?.name || "Error",
|
|
402
326
|
message: (err?.message || err?.name || "Error").slice(0, 500),
|
|
403
|
-
terminal,
|
|
404
|
-
input_hash: inputHash,
|
|
405
327
|
failed_at: checkpointTimestamp(),
|
|
406
328
|
};
|
|
407
329
|
if (exc instanceof CliError) {
|
|
330
|
+
if (exc.errorCode)
|
|
331
|
+
error["error_code"] = exc.errorCode;
|
|
408
332
|
if (exc.required.length > 0)
|
|
409
333
|
error["required"] = exc.required;
|
|
410
334
|
if (exc.received.length > 0)
|
|
@@ -415,16 +339,6 @@ function writeBatchFailure(dir, batch, exc, inputHash, providerName, model) {
|
|
|
415
339
|
const resultPath = batchResultPath(dir, batch);
|
|
416
340
|
if (exists(resultPath))
|
|
417
341
|
deletePath(resultPath);
|
|
418
|
-
if (terminal) {
|
|
419
|
-
writeUnitMeta(batchMetaPath(dir, batch), {
|
|
420
|
-
schema: 1, key: batchResultKey(batch), episode: Number(batch["episode"]), part: Number(batch["part"]),
|
|
421
|
-
input_hash: inputHash, provenance: "extracted", status: "terminal",
|
|
422
|
-
provider: providerName, model, extracted_at: checkpointTimestamp(),
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
else {
|
|
426
|
-
removeUnitMeta(batchMetaPath(dir, batch));
|
|
427
|
-
}
|
|
428
342
|
writeJson(batchErrorPath(dir, batch), error);
|
|
429
343
|
return error;
|
|
430
344
|
}
|
|
@@ -479,9 +393,14 @@ export async function commandInit(opts) {
|
|
|
479
393
|
const workspace = strOf(opts["workspace_path"] || "workspace");
|
|
480
394
|
const providerName = strOf(opts["provider"] || DEFAULT_PROVIDER);
|
|
481
395
|
const model = strOf(opts["model"] || process.env.SCRIPTCTL_ANTHROPIC_MODEL || DEFAULT_MODEL);
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
|
|
396
|
+
// Explicit selection: the agent decides what to (re)run. Default is "resume"
|
|
397
|
+
// — only batches never attempted run. --episodes / --batches force-rerun the
|
|
398
|
+
// named units, --retry-errors force-reruns everything currently errored, --all
|
|
399
|
+
// reruns everything. Forced units have their stale result/error deleted first.
|
|
400
|
+
const selEpisodes = new Set(parseIntList(opts["episodes"]));
|
|
401
|
+
const selBatches = new Set(parseStrList(opts["batches"]));
|
|
402
|
+
const retryErrors = Boolean(opts["retry_errors"]);
|
|
403
|
+
const runAll = Boolean(opts["all"]);
|
|
485
404
|
let concurrency;
|
|
486
405
|
try {
|
|
487
406
|
concurrency = parseInt(strOf(opts["concurrency"] || DEFAULT_CONCURRENCY), 10);
|
|
@@ -691,20 +610,6 @@ export async function commandInit(opts) {
|
|
|
691
610
|
// the legacy v3 index.json. There is no whole-directory reset any more.
|
|
692
611
|
gcOrphanUnits(episodeResultsDir, new Set(asList(plan["episodes"]).map((ep) => episodeResultKey(ep))));
|
|
693
612
|
gcOrphanUnits(batchResultsDir, new Set(asList(batchPlan["batches"]).map((b) => batchResultKey(b))));
|
|
694
|
-
updateRunState(workspace, {
|
|
695
|
-
status: "init_running",
|
|
696
|
-
init_stage: "batch_extract",
|
|
697
|
-
batch_mode: batchMode,
|
|
698
|
-
batch_target_lines: batchTargetLines,
|
|
699
|
-
batch_max_chars: batchMaxChars,
|
|
700
|
-
batch_min_lines: batchMinLines,
|
|
701
|
-
episode_total: asList(plan["episodes"]).length,
|
|
702
|
-
batch_total: asList(batchPlan["batches"]).length,
|
|
703
|
-
});
|
|
704
|
-
const results = [];
|
|
705
|
-
const skipped = [];
|
|
706
|
-
let skippedEpisodeBatchCount = 0;
|
|
707
|
-
const pendingBatches = [];
|
|
708
613
|
const batchesByEpisode = new Map();
|
|
709
614
|
for (const batch of asList(batchPlan["batches"])) {
|
|
710
615
|
const epNum = Number(batch["episode"]);
|
|
@@ -712,126 +617,85 @@ export async function commandInit(opts) {
|
|
|
712
617
|
batchesByEpisode.set(epNum, []);
|
|
713
618
|
batchesByEpisode.get(epNum).push(batch);
|
|
714
619
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
if (cached !== null) {
|
|
721
|
-
results.push(cached);
|
|
722
|
-
skipped.push(Number(episode["episode"]));
|
|
723
|
-
const cachedBatches = batchesByEpisode.get(Number(episode["episode"])) ?? [];
|
|
724
|
-
skippedEpisodeBatchCount += cachedBatches.length;
|
|
725
|
-
for (const cachedBatch of cachedBatches) {
|
|
726
|
-
if (!exists(batchResultPath(batchResultsDir, cachedBatch))) {
|
|
727
|
-
const backfilled = recoverBatchFromSource(sourceText, cachedBatch);
|
|
728
|
-
persistBatchResult(batchResultsDir, cachedBatch, backfilled);
|
|
729
|
-
stampBatchMeta(batchResultsDir, cachedBatch, computeUnitHash(sourceText, cachedBatch, providerName, model), "recovered", providerName, model);
|
|
730
|
-
}
|
|
731
|
-
const errorPath = batchErrorPath(batchResultsDir, cachedBatch);
|
|
732
|
-
if (exists(errorPath))
|
|
733
|
-
deletePath(errorPath);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
else {
|
|
737
|
-
pendingBatches.push(...(batchesByEpisode.get(Number(episode["episode"])) ?? []));
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
const batchResults = [];
|
|
741
|
-
const skippedBatches = [];
|
|
742
|
-
const terminalSkipped = [];
|
|
620
|
+
const totalEpisodes = asList(plan["episodes"]).length;
|
|
621
|
+
const totalBatches = asList(batchPlan["batches"]).length;
|
|
622
|
+
// Decide which batches to run. Reuse = a result file is present; an error file
|
|
623
|
+
// makes a batch "sticky" (skipped on a plain rerun so a content filter can't
|
|
624
|
+
// loop). The agent overrides this with explicit selection.
|
|
743
625
|
const pending = [];
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
626
|
+
let reusedBatchCount = 0;
|
|
627
|
+
let stickyErrorCount = 0;
|
|
628
|
+
for (const batch of asList(batchPlan["batches"])) {
|
|
629
|
+
const epNum = Number(batch["episode"]);
|
|
630
|
+
const key = batchResultKey(batch);
|
|
631
|
+
const resultPath = batchResultPath(batchResultsDir, batch);
|
|
632
|
+
const errorPath = batchErrorPath(batchResultsDir, batch);
|
|
633
|
+
const forced = runAll || selEpisodes.has(epNum) || selBatches.has(key) || (retryErrors && exists(errorPath));
|
|
634
|
+
if (forced) {
|
|
635
|
+
if (exists(resultPath))
|
|
636
|
+
deletePath(resultPath);
|
|
637
|
+
if (exists(errorPath))
|
|
638
|
+
deletePath(errorPath);
|
|
639
|
+
pending.push(batch);
|
|
753
640
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
batchResults.push(cachedBatch);
|
|
760
|
-
skippedBatches.push(batchResultKey(batch));
|
|
641
|
+
else if (exists(resultPath)) {
|
|
642
|
+
reusedBatchCount++;
|
|
643
|
+
}
|
|
644
|
+
else if (exists(errorPath)) {
|
|
645
|
+
stickyErrorCount++; // tried and failed; left alone until explicitly re-run
|
|
761
646
|
}
|
|
762
647
|
else {
|
|
763
|
-
pending.push(batch);
|
|
648
|
+
pending.push(batch); // never attempted
|
|
764
649
|
}
|
|
765
650
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
651
|
+
updateRunState(workspace, {
|
|
652
|
+
status: "init_running",
|
|
653
|
+
init_stage: "batch_extract",
|
|
654
|
+
batch_mode: batchMode,
|
|
655
|
+
batch_target_lines: batchTargetLines,
|
|
656
|
+
batch_max_chars: batchMaxChars,
|
|
657
|
+
batch_min_lines: batchMinLines,
|
|
658
|
+
episode_total: totalEpisodes,
|
|
659
|
+
batch_total: totalBatches,
|
|
769
660
|
});
|
|
661
|
+
const failures = [];
|
|
662
|
+
const outcomes = await pMapWithConcurrency(pending, concurrency, async (batch) => extractBatchWithRecovery(provider, sourceText, batch));
|
|
770
663
|
for (let i = 0; i < outcomes.length; i++) {
|
|
771
664
|
const outcome = outcomes[i];
|
|
772
665
|
const batch = pending[i];
|
|
773
|
-
const errorPath = batchErrorPath(batchResultsDir, batch);
|
|
774
666
|
if (outcome.ok) {
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
result["_batch_part"] = Number(batch["part"]);
|
|
778
|
-
result["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
|
|
779
|
-
batchResults.push(result);
|
|
780
|
-
persistBatchResult(batchResultsDir, batch, result);
|
|
781
|
-
stampBatchMeta(batchResultsDir, batch, computeUnitHash(sourceText, batch, providerName, model), "extracted", providerName, model);
|
|
667
|
+
persistBatchResult(batchResultsDir, batch, outcome.value);
|
|
668
|
+
const errorPath = batchErrorPath(batchResultsDir, batch);
|
|
782
669
|
if (exists(errorPath))
|
|
783
670
|
deletePath(errorPath);
|
|
784
671
|
}
|
|
785
672
|
else {
|
|
786
|
-
failures.push(writeBatchFailure(batchResultsDir, batch, outcome.error
|
|
673
|
+
failures.push(writeBatchFailure(batchResultsDir, batch, outcome.error));
|
|
787
674
|
}
|
|
788
675
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
});
|
|
797
|
-
failures.sort((a, b) => {
|
|
798
|
-
const ea = Number(a["episode"] ?? 0);
|
|
799
|
-
const eb = Number(b["episode"] ?? 0);
|
|
800
|
-
if (ea !== eb)
|
|
801
|
-
return ea - eb;
|
|
802
|
-
return Number(a["part"] ?? 0) - Number(b["part"] ?? 0);
|
|
803
|
-
});
|
|
804
|
-
const completedBatches = skippedEpisodeBatchCount + batchResults.length;
|
|
805
|
-
const transientFailures = failures.filter((it) => !it["terminal"]);
|
|
806
|
-
const terminalFailures = failures.filter((it) => Boolean(it["terminal"]));
|
|
807
|
-
const skippedSet = new Set(skipped);
|
|
808
|
-
// Merge every fully-completed, non-cached episode into an episode_results
|
|
809
|
-
// checkpoint. Episodes still missing a batch (a failure this run, or a batch
|
|
810
|
-
// a prior run marked terminal and we skipped) are left unmerged so a rerun or
|
|
811
|
-
// an override can complete them.
|
|
812
|
-
const batchResultsByEpisode = new Map();
|
|
813
|
-
for (const result of batchResults) {
|
|
814
|
-
const ep = Number(result["episode"] ?? 0);
|
|
815
|
-
if (!batchResultsByEpisode.has(ep))
|
|
816
|
-
batchResultsByEpisode.set(ep, []);
|
|
817
|
-
batchResultsByEpisode.get(ep).push(result);
|
|
818
|
-
}
|
|
676
|
+
const doneBatches = reusedBatchCount + outcomes.filter((o) => o.ok).length;
|
|
677
|
+
const erroredBatches = stickyErrorCount + failures.length;
|
|
678
|
+
// Assemble every episode whose batches are all present on disk (reused +
|
|
679
|
+
// freshly extracted, read uniformly from disk). Incomplete episodes are
|
|
680
|
+
// reported; assembling them is left to an explicit re-run.
|
|
681
|
+
const results = [];
|
|
682
|
+
const incompleteEpisodes = [];
|
|
819
683
|
try {
|
|
820
684
|
for (const episode of asList(plan["episodes"])) {
|
|
821
685
|
const episodeNum = Number(episode["episode"]);
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
686
|
+
const epBatches = batchesByEpisode.get(episodeNum) ?? [];
|
|
687
|
+
const batchResults = epBatches.map((b) => readBatchResult(batchResultsDir, b)).filter((r) => r !== null);
|
|
688
|
+
if (epBatches.length === 0 || batchResults.length !== epBatches.length) {
|
|
689
|
+
incompleteEpisodes.push(episodeNum);
|
|
826
690
|
continue;
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
const
|
|
833
|
-
if (exists(
|
|
834
|
-
deletePath(
|
|
691
|
+
}
|
|
692
|
+
const merged = mergeBatchResultsForEpisode(episode, batchResults);
|
|
693
|
+
validateEpisodeExtractionQuality(sourceText, episode, merged);
|
|
694
|
+
results.push(merged);
|
|
695
|
+
writeJson(episodeResultPath(episodeResultsDir, episode), compactEpisodeResult(merged));
|
|
696
|
+
const errPath = episodeErrorPath(episodeResultsDir, episode);
|
|
697
|
+
if (exists(errPath))
|
|
698
|
+
deletePath(errPath);
|
|
835
699
|
}
|
|
836
700
|
}
|
|
837
701
|
catch (exc) {
|
|
@@ -839,109 +703,60 @@ export async function commandInit(opts) {
|
|
|
839
703
|
throw initFailedReport(workspace, {
|
|
840
704
|
title: "INIT FAILED: Episode merge failed",
|
|
841
705
|
stage: "episode_merge",
|
|
842
|
-
required: ["
|
|
706
|
+
required: ["batch_results/*.json that can merge into episode_results/*.json"],
|
|
843
707
|
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
844
|
-
nextSteps: ["Rerun init; completed
|
|
845
|
-
updates: { batch_completed: completedBatches },
|
|
708
|
+
nextSteps: ["Rerun init; completed batches are reused and episode merge will retry."],
|
|
846
709
|
});
|
|
847
710
|
}
|
|
848
711
|
results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
// An episode blocked only by terminal (content-filtered) batches is held out:
|
|
852
|
-
// the rest of the script ships, and the operator overrides the blocked unit.
|
|
853
|
-
const completedEpisodeNums = new Set(results.map((r) => Number(r["episode"])));
|
|
854
|
-
const transientEpisodeSet = new Set(transientFailures.map((it) => Number(it["episode"])));
|
|
855
|
-
const incompleteEpisodes = asList(plan["episodes"]).map((ep) => Number(ep["episode"])).filter((n) => !completedEpisodeNums.has(n));
|
|
856
|
-
const transientBlocked = incompleteEpisodes.filter((n) => transientEpisodeSet.has(n)).sort((a, b) => a - b);
|
|
857
|
-
const heldOutEpisodes = incompleteEpisodes.filter((n) => !transientEpisodeSet.has(n)).sort((a, b) => a - b);
|
|
858
|
-
if (transientBlocked.length > 0) {
|
|
712
|
+
incompleteEpisodes.sort((a, b) => a - b);
|
|
713
|
+
if (incompleteEpisodes.length > 0) {
|
|
859
714
|
updateRunState(workspace, {
|
|
860
715
|
status: "init_incomplete",
|
|
861
716
|
init_stage: "batch_extract",
|
|
862
|
-
episode_total:
|
|
717
|
+
episode_total: totalEpisodes,
|
|
863
718
|
episode_completed: results.length,
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
batch_completed: completedBatches,
|
|
870
|
-
batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
|
|
871
|
-
batch_failed: failures.length,
|
|
872
|
-
batch_terminal: terminalFailures.length,
|
|
873
|
-
transient_failed_batches: transientFailures.map((it) => strOf(it["batch_id"])),
|
|
874
|
-
terminal_failed_batches: terminalFailures.map((it) => strOf(it["batch_id"])),
|
|
875
|
-
last_error: { title: "INIT INCOMPLETE: Batch extraction failed", failed_at: checkpointTimestamp() },
|
|
719
|
+
incomplete_episodes: incompleteEpisodes,
|
|
720
|
+
batch_total: totalBatches,
|
|
721
|
+
batch_completed: doneBatches,
|
|
722
|
+
batch_failed: erroredBatches,
|
|
723
|
+
last_error: { title: "INIT INCOMPLETE: Some episodes have unextracted batches", failed_at: checkpointTimestamp() },
|
|
876
724
|
});
|
|
877
|
-
const issues = failures.slice(0, 5).map((it) => `${it["batch_id"]} episode ${it["episode"]} part ${it["part"]} [${it["terminal"] ? "terminal" : "transient"}]: ${it["error_type"]} - ${it["message"]}`);
|
|
878
|
-
const next = [
|
|
879
|
-
"Run direct inspect --target issue to review failed batches.",
|
|
880
|
-
"Rerun the same init to retry transient failures; completed units are reused.",
|
|
881
|
-
];
|
|
882
|
-
if (terminalFailures.length > 0) {
|
|
883
|
-
next.push("Terminal (content-filtered) batches will not clear on retry — use `direct override <unit> --from <file>` or soften the source.");
|
|
884
|
-
}
|
|
885
725
|
const report = {
|
|
886
|
-
title: "INIT INCOMPLETE:
|
|
726
|
+
title: "INIT INCOMPLETE: Some episodes have unextracted batches",
|
|
887
727
|
result: [
|
|
888
|
-
`episodes
|
|
889
|
-
`
|
|
890
|
-
`
|
|
891
|
-
`held out (terminal): ${heldOutEpisodes.length}`,
|
|
892
|
-
`batches: ${completedBatches}/${asList(batchPlan["batches"]).length} completed, ${transientFailures.length} transient, ${terminalFailures.length} terminal`,
|
|
728
|
+
`episodes: ${results.length}/${totalEpisodes} complete`,
|
|
729
|
+
`incomplete episodes: ${incompleteEpisodes.join(", ")}`,
|
|
730
|
+
`batches: ${doneBatches}/${totalBatches} done, ${erroredBatches} error`,
|
|
893
731
|
`provider: ${providerName}`,
|
|
894
732
|
],
|
|
895
733
|
artifacts: [
|
|
896
734
|
path.join(workspace, "source.txt"),
|
|
897
|
-
path.join(dd, "source_manifest.json"),
|
|
898
735
|
path.join(dd, "episode_plan.json"),
|
|
899
736
|
path.join(dd, "batch_plan.json"),
|
|
900
737
|
batchResultsDir,
|
|
901
738
|
episodeResultsDir,
|
|
902
739
|
path.join(dd, "run_state.json"),
|
|
903
740
|
],
|
|
904
|
-
|
|
905
|
-
|
|
741
|
+
next: [
|
|
742
|
+
"Run `direct status` to see per-episode batch state.",
|
|
743
|
+
"Re-run one episode with `direct init --episodes <n>`, one batch with `--batches <key>`, or all errors with `--retry-errors`.",
|
|
744
|
+
"Errored batches are left untouched on a plain rerun; select them to retry.",
|
|
745
|
+
],
|
|
906
746
|
};
|
|
907
747
|
return [report, EXIT_RUNTIME];
|
|
908
748
|
}
|
|
909
749
|
updateRunState(workspace, {
|
|
910
750
|
status: "init_running",
|
|
911
751
|
init_stage: "episode_merge",
|
|
912
|
-
episode_total:
|
|
752
|
+
episode_total: totalEpisodes,
|
|
913
753
|
episode_completed: results.length,
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
batch_total: asList(batchPlan["batches"]).length,
|
|
919
|
-
batch_completed: completedBatches,
|
|
920
|
-
batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
|
|
921
|
-
batch_failed: terminalFailures.length,
|
|
922
|
-
batch_terminal: terminalFailures.length,
|
|
754
|
+
incomplete_episodes: [],
|
|
755
|
+
batch_total: totalBatches,
|
|
756
|
+
batch_completed: doneBatches,
|
|
757
|
+
batch_failed: erroredBatches,
|
|
923
758
|
last_error: null,
|
|
924
759
|
});
|
|
925
|
-
// Drop transient/cleared error markers, but KEEP terminal ones so `direct
|
|
926
|
-
// status` and export gating can see which episodes are held out.
|
|
927
|
-
for (const dir of [batchResultsDir, episodeResultsDir]) {
|
|
928
|
-
if (!exists(dir))
|
|
929
|
-
continue;
|
|
930
|
-
for (const name of fs.readdirSync(dir)) {
|
|
931
|
-
if (!name.endsWith(".error.json"))
|
|
932
|
-
continue;
|
|
933
|
-
const errPath = path.join(dir, name);
|
|
934
|
-
try {
|
|
935
|
-
const err = readJson(errPath);
|
|
936
|
-
if (!isDict(err) || !err["terminal"])
|
|
937
|
-
deletePath(errPath);
|
|
938
|
-
}
|
|
939
|
-
catch {
|
|
940
|
-
deletePath(errPath);
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
|
|
945
760
|
let script;
|
|
946
761
|
try {
|
|
947
762
|
updateRunState(workspace, { status: "init_running", init_stage: "script_merge" });
|
|
@@ -1050,17 +865,13 @@ export async function commandInit(opts) {
|
|
|
1050
865
|
source_path: path.resolve(source),
|
|
1051
866
|
script_path: scriptPath,
|
|
1052
867
|
validation_path: path.join(dd, "validation.json"),
|
|
1053
|
-
episode_total:
|
|
868
|
+
episode_total: totalEpisodes,
|
|
1054
869
|
episode_completed: results.length,
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
batch_completed: completedBatches,
|
|
1061
|
-
batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
|
|
1062
|
-
batch_failed: terminalFailures.length,
|
|
1063
|
-
batch_terminal: terminalFailures.length,
|
|
870
|
+
incomplete_episodes: [],
|
|
871
|
+
batch_total: totalBatches,
|
|
872
|
+
batch_completed: doneBatches,
|
|
873
|
+
batch_reused: reusedBatchCount,
|
|
874
|
+
batch_failed: erroredBatches,
|
|
1064
875
|
last_error: null,
|
|
1065
876
|
review_status: "pending",
|
|
1066
877
|
review_missing: [...REVIEW_TARGETS],
|
|
@@ -1079,9 +890,8 @@ export async function commandInit(opts) {
|
|
|
1079
890
|
`actions: ${stats["actions"] ?? 0}`,
|
|
1080
891
|
`validation: ${passed ? "passed" : "needs repair"}`,
|
|
1081
892
|
`provider: ${providerName}`,
|
|
1082
|
-
`episodes
|
|
1083
|
-
`batches: ${
|
|
1084
|
-
`batches reused: ${skippedEpisodeBatchCount + skippedBatches.length}`,
|
|
893
|
+
`episodes: ${results.length}/${totalEpisodes} complete`,
|
|
894
|
+
`batches: ${doneBatches}/${totalBatches} done (reused ${reusedBatchCount})`,
|
|
1085
895
|
"agent_review: pending",
|
|
1086
896
|
],
|
|
1087
897
|
artifacts: [
|
|
@@ -1108,138 +918,10 @@ export async function commandInit(opts) {
|
|
|
1108
918
|
return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
|
|
1109
919
|
}
|
|
1110
920
|
// ---------------------------------------------------------------------------
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
// ourselves, so the operator never hand-edits source_span.
|
|
1116
|
-
// ---------------------------------------------------------------------------
|
|
1117
|
-
export function commandOverride(opts) {
|
|
1118
|
-
const workspace = strOf(opts["workspace_path"] || "workspace");
|
|
1119
|
-
const unit = strOf(asList(opts["_args"])[0]).trim();
|
|
1120
|
-
const fromPath = strOf(opts["from"]).trim();
|
|
1121
|
-
const dd = directDir(workspace);
|
|
1122
|
-
const state = readRunState(workspace);
|
|
1123
|
-
const providerName = strOf(opts["provider"] || state["provider"] || DEFAULT_PROVIDER);
|
|
1124
|
-
const model = strOf(opts["model"] || state["model"] || DEFAULT_MODEL);
|
|
1125
|
-
const isEpisode = /^ep_\d+$/.test(unit);
|
|
1126
|
-
if (!isEpisode && !/^bat_\d+$/.test(unit)) {
|
|
1127
|
-
throw new CliError("OVERRIDE BLOCKED: Invalid unit", "Invalid unit key.", {
|
|
1128
|
-
exitCode: EXIT_USAGE,
|
|
1129
|
-
required: ["<unit>: ep_NNN or bat_NNNN"],
|
|
1130
|
-
received: [`<unit>: ${unit || "<empty>"}`],
|
|
1131
|
-
nextSteps: ["Pass an episode (ep_007) or batch (bat_0012) key shown by direct status."],
|
|
1132
|
-
});
|
|
1133
|
-
}
|
|
1134
|
-
if (!fromPath || !exists(fromPath)) {
|
|
1135
|
-
throw new CliError("OVERRIDE BLOCKED: --from not found", "Override source file not found.", {
|
|
1136
|
-
exitCode: EXIT_INPUT,
|
|
1137
|
-
required: ["--from <path>: readable JSON extraction for the unit"],
|
|
1138
|
-
received: [`--from: ${fromPath || "<missing>"}`],
|
|
1139
|
-
nextSteps: ["Provide a JSON file with scenes/actions for the unit."],
|
|
1140
|
-
});
|
|
1141
|
-
}
|
|
1142
|
-
const planPath = path.join(dd, isEpisode ? "episode_plan.json" : "batch_plan.json");
|
|
1143
|
-
if (!exists(planPath)) {
|
|
1144
|
-
throw new CliError("OVERRIDE BLOCKED: Plan not found", "Plan not found.", {
|
|
1145
|
-
exitCode: EXIT_INPUT,
|
|
1146
|
-
required: [isEpisode ? "episode_plan.json" : "batch_plan.json"],
|
|
1147
|
-
received: [planPath],
|
|
1148
|
-
nextSteps: ["Run scriptctl direct init first."],
|
|
1149
|
-
});
|
|
1150
|
-
}
|
|
1151
|
-
const plan = readJson(planPath);
|
|
1152
|
-
const planUnits = asList(plan[isEpisode ? "episodes" : "batches"]);
|
|
1153
|
-
const planItem = planUnits.find((u) => (isEpisode ? episodeResultKey(u) : batchResultKey(u)) === unit) ?? null;
|
|
1154
|
-
if (!planItem) {
|
|
1155
|
-
throw new CliError("OVERRIDE BLOCKED: Unit not in current plan", "Unit not in current plan.", {
|
|
1156
|
-
exitCode: EXIT_INPUT,
|
|
1157
|
-
required: [`${unit} present in ${isEpisode ? "episode_plan.json" : "batch_plan.json"}`],
|
|
1158
|
-
received: [`${unit}: not found among ${planUnits.length} units`],
|
|
1159
|
-
nextSteps: ["Use a unit key from direct status; rerun init if the plan changed."],
|
|
1160
|
-
});
|
|
1161
|
-
}
|
|
1162
|
-
const sourceTextPath = path.join(workspace, "source.txt");
|
|
1163
|
-
if (!exists(sourceTextPath)) {
|
|
1164
|
-
throw new CliError("OVERRIDE BLOCKED: source.txt missing", "source.txt missing.", {
|
|
1165
|
-
exitCode: EXIT_INPUT,
|
|
1166
|
-
required: [sourceTextPath],
|
|
1167
|
-
received: ["<missing>"],
|
|
1168
|
-
nextSteps: ["Run scriptctl direct init first."],
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
|
-
const sourceText = readText(sourceTextPath);
|
|
1172
|
-
let data;
|
|
1173
|
-
try {
|
|
1174
|
-
data = readJson(fromPath);
|
|
1175
|
-
}
|
|
1176
|
-
catch (exc) {
|
|
1177
|
-
throw new CliError("OVERRIDE BLOCKED: --from invalid JSON", "Override JSON invalid.", {
|
|
1178
|
-
exitCode: EXIT_INPUT,
|
|
1179
|
-
required: ["valid extraction JSON"],
|
|
1180
|
-
received: [`${fromPath}: ${exc.message}`],
|
|
1181
|
-
nextSteps: ["Fix the JSON and retry."],
|
|
1182
|
-
});
|
|
1183
|
-
}
|
|
1184
|
-
const result = normalizeEpisodeResult(data, planItem);
|
|
1185
|
-
if (Number(result["episode"]) !== Number(planItem["episode"])) {
|
|
1186
|
-
throw new CliError("OVERRIDE BLOCKED: Episode mismatch", "Episode mismatch.", {
|
|
1187
|
-
exitCode: EXIT_USAGE,
|
|
1188
|
-
required: [`episode ${Number(planItem["episode"])}`],
|
|
1189
|
-
received: [`episode ${Number(result["episode"])}`],
|
|
1190
|
-
nextSteps: ["Provide an extraction for the correct episode."],
|
|
1191
|
-
});
|
|
1192
|
-
}
|
|
1193
|
-
try {
|
|
1194
|
-
if (isEpisode)
|
|
1195
|
-
validateEpisodeExtractionQuality(sourceText, planItem, result);
|
|
1196
|
-
else
|
|
1197
|
-
validateBatchExtractionQuality(sourceText, planItem, result);
|
|
1198
|
-
}
|
|
1199
|
-
catch (exc) {
|
|
1200
|
-
if (exc instanceof CliError)
|
|
1201
|
-
throw exc;
|
|
1202
|
-
throw new CliError("OVERRIDE BLOCKED: Extraction invalid", "Extraction invalid.", {
|
|
1203
|
-
exitCode: EXIT_USAGE,
|
|
1204
|
-
required: ["valid action types (dialogue/inner_thought/action)"],
|
|
1205
|
-
received: [exc.message.slice(0, 160)],
|
|
1206
|
-
nextSteps: ["Fix the override extraction and retry."],
|
|
1207
|
-
});
|
|
1208
|
-
}
|
|
1209
|
-
const dir = path.join(dd, isEpisode ? "episode_results" : "batch_results");
|
|
1210
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1211
|
-
const hash = computeUnitHash(sourceText, planItem, providerName, model);
|
|
1212
|
-
if (isEpisode) {
|
|
1213
|
-
writeJson(episodeResultPath(dir, planItem), compactEpisodeResult(result));
|
|
1214
|
-
stampEpisodeMeta(dir, planItem, hash, "override", providerName, model);
|
|
1215
|
-
const errPath = episodeErrorPath(dir, planItem);
|
|
1216
|
-
if (exists(errPath))
|
|
1217
|
-
deletePath(errPath);
|
|
1218
|
-
}
|
|
1219
|
-
else {
|
|
1220
|
-
persistBatchResult(dir, planItem, result);
|
|
1221
|
-
stampBatchMeta(dir, planItem, hash, "override", providerName, model);
|
|
1222
|
-
const errPath = batchErrorPath(dir, planItem);
|
|
1223
|
-
if (exists(errPath))
|
|
1224
|
-
deletePath(errPath);
|
|
1225
|
-
}
|
|
1226
|
-
const report = {
|
|
1227
|
-
title: "OVERRIDE COMPLETE: Unit extraction injected",
|
|
1228
|
-
result: [
|
|
1229
|
-
`unit: ${unit}`,
|
|
1230
|
-
`kind: ${isEpisode ? "episode" : "batch"}`,
|
|
1231
|
-
`provenance: override`,
|
|
1232
|
-
`provider/model: ${providerName} / ${model}`,
|
|
1233
|
-
`scenes: ${asList(result["scenes"]).length}`,
|
|
1234
|
-
],
|
|
1235
|
-
artifacts: [dir, path.join(dd, "run_state.json")],
|
|
1236
|
-
next: ["Rerun scriptctl direct init — the override is reused without re-calling the provider."],
|
|
1237
|
-
};
|
|
1238
|
-
return [report, EXIT_OK];
|
|
1239
|
-
}
|
|
1240
|
-
// ---------------------------------------------------------------------------
|
|
1241
|
-
// command_status — rebuild the progress view from on-disk meta/error sidecars.
|
|
1242
|
-
// run_state is just a cache of this; deleting it loses nothing.
|
|
921
|
+
// command_status — the agent-facing state table, derived entirely from disk: a
|
|
922
|
+
// batch with `<key>.json` is done, with `<key>.error.json` it failed, with
|
|
923
|
+
// neither it is pending. run_state is just a denormalized cache of this, so the
|
|
924
|
+
// agent can read state, then pick exactly which episodes/batches to (re)run.
|
|
1243
925
|
// ---------------------------------------------------------------------------
|
|
1244
926
|
export function commandStatus(opts) {
|
|
1245
927
|
const workspace = strOf(opts["workspace_path"] || "workspace");
|
|
@@ -1256,54 +938,67 @@ export function commandStatus(opts) {
|
|
|
1256
938
|
}
|
|
1257
939
|
const episodes = asList(readJson(episodePlanPath)["episodes"]);
|
|
1258
940
|
const batches = asList(readJson(batchPlanPath)["batches"]);
|
|
1259
|
-
const episodeResultsDir = path.join(dd, "episode_results");
|
|
1260
941
|
const batchResultsDir = path.join(dd, "batch_results");
|
|
1261
|
-
const
|
|
942
|
+
const byEp = new Map();
|
|
943
|
+
let doneTotal = 0;
|
|
944
|
+
let errorTotal = 0;
|
|
1262
945
|
for (const batch of batches) {
|
|
1263
|
-
const
|
|
1264
|
-
if (!
|
|
1265
|
-
|
|
946
|
+
const epNum = Number(batch["episode"]);
|
|
947
|
+
if (!byEp.has(epNum))
|
|
948
|
+
byEp.set(epNum, { total: 0, done: 0, error: 0, errorKeys: [], reasons: new Set() });
|
|
949
|
+
const st = byEp.get(epNum);
|
|
950
|
+
st.total++;
|
|
951
|
+
if (exists(batchResultPath(batchResultsDir, batch))) {
|
|
952
|
+
st.done++;
|
|
953
|
+
doneTotal++;
|
|
1266
954
|
continue;
|
|
1267
955
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
956
|
+
const errPath = batchErrorPath(batchResultsDir, batch);
|
|
957
|
+
if (exists(errPath)) {
|
|
958
|
+
st.error++;
|
|
959
|
+
errorTotal++;
|
|
960
|
+
st.errorKeys.push(batchResultKey(batch));
|
|
961
|
+
try {
|
|
962
|
+
const err = readJson(errPath);
|
|
963
|
+
st.reasons.add(strOf(err["error_code"] || err["error_type"] || "Error"));
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
st.reasons.add("Error");
|
|
967
|
+
}
|
|
1271
968
|
}
|
|
1272
|
-
count.ok++;
|
|
1273
|
-
if (meta["provenance"] === "override")
|
|
1274
|
-
count.override++;
|
|
1275
|
-
else if (meta["provenance"] === "recovered")
|
|
1276
|
-
count.recovered++;
|
|
1277
969
|
}
|
|
1278
|
-
const
|
|
970
|
+
const lines = [];
|
|
971
|
+
let completeEpisodes = 0;
|
|
972
|
+
const errorEpisodes = [];
|
|
1279
973
|
for (const ep of episodes) {
|
|
1280
|
-
const
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
974
|
+
const epNum = Number(ep["episode"]);
|
|
975
|
+
const st = byEp.get(epNum) ?? { total: 0, done: 0, error: 0, errorKeys: [], reasons: new Set() };
|
|
976
|
+
let label;
|
|
977
|
+
if (st.total > 0 && st.done === st.total) {
|
|
978
|
+
label = "done";
|
|
979
|
+
completeEpisodes++;
|
|
980
|
+
}
|
|
981
|
+
else if (st.error > 0) {
|
|
982
|
+
label = `ERROR (${st.done}/${st.total} done, ${st.error} err: ${[...st.reasons].join(",")}) [${st.errorKeys.join(",")}]`;
|
|
983
|
+
errorEpisodes.push(epNum);
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
label = `pending (${st.done}/${st.total} done)`;
|
|
987
|
+
}
|
|
988
|
+
lines.push(`${episodeResultKey(ep)} ${label}`);
|
|
1292
989
|
}
|
|
1293
|
-
const heldOutEpisodes = [...heldOut].sort((a, b) => a - b);
|
|
1294
990
|
const report = {
|
|
1295
991
|
title: "DIRECT STATUS",
|
|
1296
992
|
result: [
|
|
1297
|
-
`episodes: ${
|
|
1298
|
-
`batches: ${
|
|
1299
|
-
`
|
|
1300
|
-
|
|
1301
|
-
`held out episodes: ${heldOutEpisodes.length === 0 ? "-" : heldOutEpisodes.join(", ")}`,
|
|
993
|
+
`episodes: ${completeEpisodes}/${episodes.length} done`,
|
|
994
|
+
`batches: ${doneTotal}/${batches.length} done, ${errorTotal} error`,
|
|
995
|
+
`error episodes: ${errorEpisodes.length === 0 ? "-" : errorEpisodes.join(", ")}`,
|
|
996
|
+
...lines,
|
|
1302
997
|
],
|
|
1303
|
-
artifacts: [batchResultsDir,
|
|
1304
|
-
next:
|
|
1305
|
-
? [
|
|
1306
|
-
: ["All
|
|
998
|
+
artifacts: [batchResultsDir, path.join(dd, "episode_results"), path.join(dd, "run_state.json")],
|
|
999
|
+
next: errorEpisodes.length > 0
|
|
1000
|
+
? [`Re-run an errored episode with: direct init --episodes ${errorEpisodes[0]} (or --batches <key>, or --retry-errors).`]
|
|
1001
|
+
: ["All batches accounted for."],
|
|
1307
1002
|
};
|
|
1308
1003
|
return [report, EXIT_OK];
|
|
1309
1004
|
}
|