@tiangong-ai/cli 0.0.4 → 0.0.7

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 CHANGED
@@ -19,6 +19,7 @@ const DEFAULT_BULK_POLL_INTERVAL_SECONDS = 30;
19
19
  const DEFAULT_BULK_MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
20
20
  const DEFAULT_BULK_DERIVED_DIR = ".tiangong-kb-ingest-derived";
21
21
  const DOCX_TARGET_IMAGE_DPI = 300;
22
+ const DOCX_NORMALIZE_MIN_BYTES = 10 * 1024 * 1024;
22
23
  const EMUS_PER_INCH = 914400;
23
24
  const RETRYABLE_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
24
25
  const TERMINAL_STATUSES = new Set(["completed", "failed", "deleted"]);
@@ -249,6 +250,7 @@ async function kbIngestStatus(argv, io) {
249
250
  `status=${job.status}`,
250
251
  `state=${job.statePath}`,
251
252
  `root=${job.rootPath}`,
253
+ `pressure=${job.pipelineHealth?.pressure ?? "unknown"} action=${job.pipelineHealth?.recommendedAction ?? "continue"}`,
252
254
  `files=${summary.total} pending=${summary.pending} inflight=${summary.inflight} completed=${summary.completed} failed=${summary.failed}`,
253
255
  "",
254
256
  ].join("\n"));
@@ -400,7 +402,8 @@ async function kbIngestBulkRun(argv, io) {
400
402
  const metadataMap = await loadMetadataMap(args);
401
403
  const files = await collectBulkFilePlans(root, true, args);
402
404
  const schemaSnapshot = await loadSchemaSnapshot(args, io.env, config, selector);
403
- const preflightPlans = await prepareBulkPreflightPlans(files, preflightOptionsFromArgs(args, schemaSnapshot, root, true));
405
+ const preflightOptions = preflightOptionsFromArgs(args, schemaSnapshot, root, false);
406
+ const preflightPlans = await prepareBulkPreflightPlans(files, preflightOptions);
404
407
  const dryRunSummary = metadataDryRun(preflightPlans.allPlans, metadataMap, schemaSnapshot);
405
408
  dryRunSummary.preflight = buildPreflightSummary(preflightPlans.allPlans, preflightPlans.maxUploadBytes);
406
409
  await initializeBulkJob({
@@ -421,6 +424,7 @@ async function kbIngestBulkRun(argv, io) {
421
424
  metadataMap,
422
425
  env: io.env,
423
426
  stdout: io.stdout,
427
+ preflightOptions,
424
428
  });
425
429
  writeJsonOrText(io.stdout, args, result, () => formatBulkRunSummary(result));
426
430
  return result.failed > 0 || result.blocked > 0 ? 1 : 0;
@@ -449,7 +453,7 @@ async function kbIngestJobs(argv, io) {
449
453
  }
450
454
  }))).filter((job) => Boolean(job));
451
455
  writeJsonOrText(io.stdout, args, { jobs }, () => jobs
452
- .map((job) => `${job.jobId}\t${job.status}\t${job.summary.completed}/${job.summary.total}\t${job.statePath}`)
456
+ .map((job) => `${job.jobId}\t${job.status}\t${job.summary.completed}/${job.summary.total}\tpressure=${job.pipelineHealth?.pressure ?? "unknown"}\taction=${job.pipelineHealth?.recommendedAction ?? "continue"}\t${job.statePath}`)
453
457
  .join("\n")
454
458
  .concat(jobs.length ? "\n" : ""));
455
459
  return 0;
@@ -465,6 +469,7 @@ async function kbIngestResume(argv, io) {
465
469
  const job = await readBulkJob(statePath);
466
470
  const config = resolveConfig(args, io.env);
467
471
  const selectorFields = await resolveSelectorFields(config, job.collectionSelector);
472
+ const preflightOptions = preflightOptionsFromArgs(args, job.schemaSnapshot, job.rootPath, false);
468
473
  const result = await runBulkLoop({
469
474
  args,
470
475
  config,
@@ -473,6 +478,7 @@ async function kbIngestResume(argv, io) {
473
478
  metadataMap: job.metadataMap,
474
479
  env: io.env,
475
480
  stdout: io.stdout,
481
+ preflightOptions,
476
482
  });
477
483
  writeJsonOrText(io.stdout, args, result, () => formatBulkRunSummary(result));
478
484
  return result.failed > 0 || result.blocked > 0 ? 1 : 0;
@@ -487,6 +493,7 @@ async function kbIngestExport(argv, io) {
487
493
  throw new CliError(`Bulk job not found: ${jobId}`);
488
494
  const format = getString(args, "format") ?? "jsonl";
489
495
  const rows = await readBulkFiles(statePath);
496
+ const job = await readBulkJob(statePath);
490
497
  if (format === "csv") {
491
498
  write(io.stdout, [
492
499
  csvLine([
@@ -534,7 +541,7 @@ async function kbIngestExport(argv, io) {
534
541
  throw new CliError("--format must be jsonl, json, or csv.");
535
542
  }
536
543
  if (format === "json") {
537
- write(io.stdout, `${JSON.stringify({ files: rows }, null, 2)}\n`);
544
+ write(io.stdout, `${JSON.stringify({ job, files: rows }, null, 2)}\n`);
538
545
  }
539
546
  else {
540
547
  write(io.stdout, rows
@@ -554,30 +561,42 @@ async function runBulkLoop(input) {
554
561
  let polls = 0;
555
562
  await resetInterruptedBulkUploads(input.statePath);
556
563
  await updateJobStatus(input.statePath, "running");
564
+ let lastPipelineHealth;
557
565
  while (true) {
558
566
  polls += 1;
559
567
  await pollBulkStatuses(input.statePath, input.config);
560
568
  const summary = await bulkJobSummary(input.statePath);
561
569
  const capacity = Math.max(0, windowSize - summary.inflight);
562
- const uploadLimit = Math.min(capacity, topUpMax);
570
+ lastPipelineHealth = await readBulkPipelineHealth(input.config, pollInterval);
571
+ await saveBulkPipelineHealth(input.statePath, lastPipelineHealth);
572
+ const uploadLimit = bulkUploadLimitForHealth(Math.min(capacity, topUpMax), lastPipelineHealth);
573
+ const effectiveUploadConcurrency = lastPipelineHealth.recommendedAction === "slow_down" ? 1 : uploadConcurrency;
563
574
  if (uploadLimit > 0) {
564
575
  const pending = await claimPendingBulkFiles(input.statePath, uploadLimit);
565
- await runPool(pending, uploadConcurrency, async (row) => {
566
- const result = await uploadBulkFile({
567
- args: input.args,
568
- config: input.config,
569
- selectorFields: input.selectorFields,
576
+ await runPool(pending, effectiveUploadConcurrency, async (row) => {
577
+ const materialized = await materializeBulkFileForUpload({
578
+ statePath: input.statePath,
579
+ metadataMap: input.metadataMap,
580
+ preflightOptions: input.preflightOptions,
570
581
  row,
571
- retries,
572
- env: input.env,
573
582
  });
574
- await saveBulkUploadResult(input.statePath, row.relativePath, result);
575
- return result;
583
+ return await runPool(materialized.uploadRows, 1, async (uploadRow) => {
584
+ const result = await uploadBulkFile({
585
+ args: input.args,
586
+ config: input.config,
587
+ selectorFields: input.selectorFields,
588
+ row: uploadRow,
589
+ retries,
590
+ env: input.env,
591
+ });
592
+ await saveBulkUploadResult(input.statePath, uploadRow.relativePath, result);
593
+ return result;
594
+ });
576
595
  });
577
596
  }
578
597
  const nextSummary = await bulkJobSummary(input.statePath);
579
598
  if (getBoolean(input.args, "verbose")) {
580
- write(input.stdout, `poll=${polls} pending=${nextSummary.pending} inflight=${nextSummary.inflight} completed=${nextSummary.completed} failed=${nextSummary.failed} waiting_for_index_flags=${nextSummary.waitingForIndexFlags}\n`);
599
+ write(input.stdout, `poll=${polls} pending=${nextSummary.pending} inflight=${nextSummary.inflight} completed=${nextSummary.completed} failed=${nextSummary.failed} waiting_for_index_flags=${nextSummary.waitingForIndexFlags} pressure=${lastPipelineHealth.pressure} action=${lastPipelineHealth.recommendedAction}\n`);
581
600
  }
582
601
  if (nextSummary.pending === 0 && nextSummary.inflight === 0) {
583
602
  await updateJobStatus(input.statePath, nextSummary.failed > 0 || nextSummary.blocked > 0 ? "failed" : "completed");
@@ -586,6 +605,7 @@ async function runBulkLoop(input) {
586
605
  jobId: jobIdFromStatePath(input.statePath),
587
606
  statePath: input.statePath,
588
607
  polls,
608
+ pipelineHealth: lastPipelineHealth,
589
609
  };
590
610
  }
591
611
  if (maxPolls > 0 && polls >= maxPolls) {
@@ -595,11 +615,28 @@ async function runBulkLoop(input) {
595
615
  jobId: jobIdFromStatePath(input.statePath),
596
616
  statePath: input.statePath,
597
617
  polls,
618
+ pipelineHealth: lastPipelineHealth,
598
619
  };
599
620
  }
600
- await sleep(pollInterval * 1000);
621
+ await sleep(bulkPollIntervalForHealth(pollInterval, lastPipelineHealth) * 1000);
601
622
  }
602
623
  }
624
+ function bulkUploadLimitForHealth(limit, health) {
625
+ if (limit <= 0)
626
+ return 0;
627
+ if (health.recommendedAction === "pause_top_up")
628
+ return 0;
629
+ if (health.recommendedAction === "slow_down")
630
+ return Math.max(1, Math.ceil(limit / 2));
631
+ return limit;
632
+ }
633
+ function bulkPollIntervalForHealth(baseSeconds, health) {
634
+ if (!health)
635
+ return baseSeconds;
636
+ if (health.recommendedAction === "continue")
637
+ return baseSeconds;
638
+ return Math.max(baseSeconds, health.recommendedPollAfterSeconds);
639
+ }
603
640
  async function uploadBulkFile(input) {
604
641
  const plan = {
605
642
  path: input.row.path,
@@ -620,6 +657,143 @@ async function uploadBulkFile(input) {
620
657
  env: input.env,
621
658
  });
622
659
  }
660
+ async function materializeBulkFileForUpload(input) {
661
+ if (input.row.ingestVariant === "direct_upload") {
662
+ return { uploadRows: [input.row] };
663
+ }
664
+ try {
665
+ const preflightOptions = materializeOptionsForRow(input.preflightOptions, input.row);
666
+ if (input.row.ingestVariant === "compressed_docx") {
667
+ const docxRow = await materializeDocxBulkRow(input.statePath, input.metadataMap, preflightOptions, input.row);
668
+ return { uploadRows: docxRow ? [docxRow] : [] };
669
+ }
670
+ if (input.row.ingestVariant === "page_split_pdf") {
671
+ const pdfRows = await materializePdfBulkRow(input.statePath, input.metadataMap, preflightOptions, input.row);
672
+ return { uploadRows: pdfRows };
673
+ }
674
+ await markBulkFileFailed(input.statePath, input.row.relativePath, "UNSUPPORTED_INGEST_VARIANT");
675
+ return { uploadRows: [] };
676
+ }
677
+ catch (error) {
678
+ await markBulkFileFailed(input.statePath, input.row.relativePath, error instanceof Error ? error.message : String(error));
679
+ return { uploadRows: [] };
680
+ }
681
+ }
682
+ function materializeOptionsForRow(options, row) {
683
+ const rowMaxUploadBytes = Number(row.preflight.maxUploadBytes);
684
+ return {
685
+ ...options,
686
+ maxUploadBytes: Number.isFinite(rowMaxUploadBytes) && rowMaxUploadBytes > 0
687
+ ? rowMaxUploadBytes
688
+ : options.maxUploadBytes,
689
+ };
690
+ }
691
+ async function materializeDocxBulkRow(statePath, metadataMap, options, row) {
692
+ if (row.derivedPath && row.derivedSize !== undefined && row.derivedSha256) {
693
+ const existing = await stat(row.derivedPath).catch(() => undefined);
694
+ if (existing?.isFile() && existing.size === row.derivedSize)
695
+ return row;
696
+ }
697
+ const source = bulkRecordToOriginalPlan(row);
698
+ const docx = await analyzeDocx(source.originalPath);
699
+ if (isEmptyDocxAnalysis(docx)) {
700
+ await markBulkFileSkipped(statePath, row.relativePath, "empty_docx");
701
+ return undefined;
702
+ }
703
+ const materialized = await createDocxIngestCopy(source, options, docx, row.classification);
704
+ return await updateMaterializedBulkRow(statePath, metadataMap, row.relativePath, materialized);
705
+ }
706
+ async function materializePdfBulkRow(statePath, metadataMap, options, row) {
707
+ if (row.partIndex !== undefined && row.derivedPath) {
708
+ const existing = await stat(row.derivedPath).catch(() => undefined);
709
+ if (existing?.isFile() && existing.size === row.derivedSize)
710
+ return [row];
711
+ }
712
+ const source = bulkRecordToOriginalPlan(row);
713
+ const pdf = (await analyzePdf(source.originalPath).catch((error) => ({
714
+ error: error instanceof Error ? error.message : String(error),
715
+ pageCount: 0,
716
+ imageCount: 0,
717
+ imageHeavy: false,
718
+ })));
719
+ const partPlans = await createPdfPartPlans(source, options, pdf, row.classification);
720
+ if (row.partIndex !== undefined) {
721
+ const currentPart = partPlans.find((part) => part.relativePath === row.relativePath);
722
+ if (!currentPart) {
723
+ await markBulkFileFailed(statePath, row.relativePath, "PDF_SPLIT_PART_REGENERATION_MISMATCH");
724
+ return [];
725
+ }
726
+ return [await updateMaterializedBulkRow(statePath, metadataMap, row.relativePath, currentPart)];
727
+ }
728
+ const now = new Date().toISOString();
729
+ const db = await openSqlite(statePath);
730
+ try {
731
+ createBulkSchema(db);
732
+ db.exec("BEGIN");
733
+ try {
734
+ for (const part of partPlans) {
735
+ insertBulkFilePlan(db, metadataMap, part, bulkInitialStatus(part), now);
736
+ }
737
+ db.prepare("DELETE FROM files WHERE relative_path = ?").run(row.relativePath);
738
+ touchJob(db, now);
739
+ db.exec("COMMIT");
740
+ }
741
+ catch (error) {
742
+ db.exec("ROLLBACK");
743
+ throw error;
744
+ }
745
+ }
746
+ finally {
747
+ db.close();
748
+ }
749
+ return [];
750
+ }
751
+ async function updateMaterializedBulkRow(statePath, metadataMap, relativePath, file) {
752
+ const evaluated = evaluateMetadata(metadataMap, file);
753
+ const now = new Date().toISOString();
754
+ const db = await openSqlite(statePath);
755
+ try {
756
+ createBulkSchema(db);
757
+ db.prepare(`UPDATE files
758
+ SET path = ?, size = ?, mtime_ms = ?, sha256 = ?, ext = ?, path_segments_json = ?, path_depth = ?,
759
+ metadata_json = ?, matched_rules_json = ?, classification = ?, ingest_variant = ?, source_document_key = ?,
760
+ derived_path = ?, derived_size = ?, derived_sha256 = ?, normalize_strategy = ?, generated_metadata_json = ?,
761
+ preflight_json = ?, updated_at = ?
762
+ WHERE relative_path = ?`).run(file.path, file.size, file.mtimeMs, file.sha256, file.ext, JSON.stringify(file.pathSegments), file.pathDepth, JSON.stringify(evaluated.metadata), JSON.stringify(evaluated.matchedRules), file.classification, file.ingestVariant, file.sourceDocumentKey, file.derivedPath ?? null, file.derivedSize ?? null, file.derivedSha256 ?? null, file.normalizeStrategy ?? null, JSON.stringify(file.generatedMetadata), JSON.stringify(file.preflight), now, relativePath);
763
+ touchJob(db, now);
764
+ const updated = db.prepare("SELECT * FROM files WHERE relative_path = ?").get(relativePath);
765
+ if (!updated)
766
+ throw new CliError(`Bulk row disappeared during materialization: ${relativePath}`);
767
+ return rowToBulkFile(updated);
768
+ }
769
+ finally {
770
+ db.close();
771
+ }
772
+ }
773
+ async function markBulkFileFailed(statePath, relativePath, error) {
774
+ const db = await openSqlite(statePath);
775
+ const now = new Date().toISOString();
776
+ try {
777
+ createBulkSchema(db);
778
+ db.prepare("UPDATE files SET status = 'failed', last_error = ?, updated_at = ? WHERE relative_path = ?").run(error, now, relativePath);
779
+ touchJob(db, now);
780
+ }
781
+ finally {
782
+ db.close();
783
+ }
784
+ }
785
+ async function markBulkFileSkipped(statePath, relativePath, reason) {
786
+ const db = await openSqlite(statePath);
787
+ const now = new Date().toISOString();
788
+ try {
789
+ createBulkSchema(db);
790
+ db.prepare("UPDATE files SET status = 'skipped', last_error = ?, updated_at = ? WHERE relative_path = ?").run(reason, now, relativePath);
791
+ touchJob(db, now);
792
+ }
793
+ finally {
794
+ db.close();
795
+ }
796
+ }
623
797
  async function pollBulkStatuses(statePath, config) {
624
798
  const inflight = await readInflightBulkFiles(statePath);
625
799
  const documentIds = inflight.map((row) => row.documentId).filter(Boolean);
@@ -645,6 +819,12 @@ async function pollBulkStatuses(statePath, config) {
645
819
  }
646
820
  }
647
821
  function judgeBulkStatus(item) {
822
+ if (item.itemError) {
823
+ const message = `${item.itemError.code}: ${item.itemError.message}`;
824
+ return item.itemError.retryable
825
+ ? { status: "uploaded", lastError: message }
826
+ : { status: "failed", lastError: message };
827
+ }
648
828
  const status = item.status ?? "";
649
829
  if (BULK_FAILED_STATUSES.has(status)) {
650
830
  return {
@@ -693,12 +873,12 @@ function batchStatusItems(payload) {
693
873
  const data = responseData(payload);
694
874
  const items = isObject(data) && Array.isArray(data.documents)
695
875
  ? data.documents
696
- : Array.isArray(data)
697
- ? data
698
- : [];
699
- return items
700
- .filter(isObject)
701
- .map((item) => statusItemFromPayload(item, stringField(item, "documentId") ?? stringField(item, "document_id") ?? ""));
876
+ : isObject(data) && Array.isArray(data.results)
877
+ ? data.results
878
+ : Array.isArray(data)
879
+ ? data
880
+ : [];
881
+ return items.filter(isObject).map((item) => batchStatusItemFromPayload(item));
702
882
  }
703
883
  function statusItemFromPayload(payload, fallbackDocumentId = "") {
704
884
  const data = responseData(payload);
@@ -715,6 +895,90 @@ function statusItemFromPayload(payload, fallbackDocumentId = "") {
715
895
  raw: payload,
716
896
  };
717
897
  }
898
+ async function readBulkPipelineHealth(config, fallbackPollAfterSeconds) {
899
+ try {
900
+ const payload = await jsonRequest(config, "pipeline/health");
901
+ return pipelineHealthFromPayload(payload, fallbackPollAfterSeconds);
902
+ }
903
+ catch (error) {
904
+ if (error instanceof HttpError && error.status && [404, 405, 501].includes(error.status)) {
905
+ return {
906
+ healthy: true,
907
+ pressure: "unknown",
908
+ recommendedAction: "continue",
909
+ recommendedPollAfterSeconds: fallbackPollAfterSeconds,
910
+ message: "Pipeline health endpoint is unavailable; continuing without backpressure.",
911
+ };
912
+ }
913
+ if (error instanceof HttpError) {
914
+ return {
915
+ healthy: false,
916
+ pressure: "paused",
917
+ recommendedAction: "pause_top_up",
918
+ recommendedPollAfterSeconds: error.retryAfterSeconds ?? Math.max(fallbackPollAfterSeconds, 30),
919
+ message: error.message,
920
+ };
921
+ }
922
+ return {
923
+ healthy: false,
924
+ pressure: "paused",
925
+ recommendedAction: "pause_top_up",
926
+ recommendedPollAfterSeconds: Math.max(fallbackPollAfterSeconds, 30),
927
+ message: error instanceof Error ? error.message : String(error),
928
+ };
929
+ }
930
+ }
931
+ function pipelineHealthFromPayload(payload, fallbackPollAfterSeconds) {
932
+ const data = responseData(payload);
933
+ if (!isObject(data)) {
934
+ throw new CliError("Pipeline health response did not contain an object payload.");
935
+ }
936
+ const action = stringField(data, "recommendedAction");
937
+ if (action !== "continue" && action !== "slow_down" && action !== "pause_top_up") {
938
+ throw new CliError("Pipeline health response did not contain a valid recommendedAction.");
939
+ }
940
+ const pressure = stringField(data, "pressure");
941
+ const pollAfter = Number(data.recommendedPollAfterSeconds);
942
+ return {
943
+ healthy: typeof data.healthy === "boolean" ? data.healthy : action === "continue",
944
+ pressure: pressure === "ok" || pressure === "degraded" || pressure === "paused" ? pressure : "unknown",
945
+ recommendedAction: action,
946
+ recommendedPollAfterSeconds: Number.isFinite(pollAfter) && pollAfter > 0 ? pollAfter : fallbackPollAfterSeconds,
947
+ checkedAt: stringField(data, "checkedAt"),
948
+ message: stringField(data, "message") ??
949
+ (isObject(data.indexPreflight) ? stringField(data.indexPreflight, "message") : undefined),
950
+ };
951
+ }
952
+ async function saveBulkPipelineHealth(statePath, health) {
953
+ const db = await openSqlite(statePath);
954
+ const now = new Date().toISOString();
955
+ try {
956
+ createBulkSchema(db);
957
+ db.prepare("UPDATE jobs SET pipeline_health_json = ?, updated_at = ?").run(JSON.stringify(health), now);
958
+ }
959
+ finally {
960
+ db.close();
961
+ }
962
+ }
963
+ function batchStatusItemFromPayload(item) {
964
+ const documentId = stringField(item, "documentId") ?? stringField(item, "document_id") ?? "";
965
+ if (item.ok === true && isObject(item.status)) {
966
+ return statusItemFromPayload(item.status, documentId);
967
+ }
968
+ if (item.ok === false && isObject(item.error)) {
969
+ const error = item.error;
970
+ return {
971
+ documentId,
972
+ itemError: {
973
+ code: stringField(error, "code") ?? "STATUS_ITEM_ERROR",
974
+ message: stringField(error, "message") ?? "Document status lookup failed.",
975
+ retryable: typeof error.retryable === "boolean" ? error.retryable : false,
976
+ },
977
+ raw: item,
978
+ };
979
+ }
980
+ return statusItemFromPayload(item, documentId);
981
+ }
718
982
  async function initializeBulkJob(input) {
719
983
  await mkdir(dirname(input.statePath), { recursive: true });
720
984
  const db = await openSqlite(input.statePath);
@@ -726,15 +990,9 @@ async function initializeBulkJob(input) {
726
990
  db.prepare(`INSERT INTO jobs (job_id, root_path, collection_selector_json, schema_snapshot_json, metadata_map_json, dry_run_summary_json, status, created_at, updated_at)
727
991
  VALUES (?, ?, ?, ?, ?, ?, 'created', ?, ?)`).run(input.jobId, input.rootPath, JSON.stringify(input.selector), JSON.stringify(input.schemaSnapshot ?? null), JSON.stringify(input.metadataMap), JSON.stringify(input.dryRunSummary), now, now);
728
992
  }
729
- const insert = db.prepare(`INSERT OR IGNORE INTO files
730
- (path, relative_path, size, mtime_ms, sha256, ext, path_segments_json, path_depth, metadata_json, matched_rules_json, status, attempts, created_at, updated_at,
731
- original_path, original_relative_path, original_size, original_mtime_ms, original_sha256, original_ext, classification, ingest_variant, source_document_key,
732
- derived_path, derived_size, derived_sha256, normalize_strategy, part_index, part_count, page_start, page_end, generated_metadata_json, preflight_json)
733
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
734
993
  for (const file of input.files) {
735
- const evaluated = evaluateMetadata(input.metadataMap, file);
736
994
  const status = bulkInitialStatus(file);
737
- insert.run(file.path, file.relativePath, file.size, file.mtimeMs, file.sha256, file.ext, JSON.stringify(file.pathSegments), file.pathDepth, JSON.stringify(evaluated.metadata), JSON.stringify(evaluated.matchedRules), status, now, now, file.originalPath, file.originalRelativePath, file.originalSize, file.originalMtimeMs, file.originalSha256, file.originalExt, file.classification, file.ingestVariant, file.sourceDocumentKey, file.derivedPath ?? null, file.derivedSize ?? null, file.derivedSha256 ?? null, file.normalizeStrategy ?? null, file.partIndex ?? null, file.partCount ?? null, file.pageStart ?? null, file.pageEnd ?? null, JSON.stringify(file.generatedMetadata), JSON.stringify(file.preflight));
995
+ insertBulkFilePlan(db, input.metadataMap, file, status, now);
738
996
  }
739
997
  touchJob(db, now);
740
998
  }
@@ -749,6 +1007,14 @@ async function openSqlite(path) {
749
1007
  db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
750
1008
  return db;
751
1009
  }
1010
+ function insertBulkFilePlan(db, metadataMap, file, status, now) {
1011
+ const evaluated = evaluateMetadata(metadataMap, file);
1012
+ db.prepare(`INSERT OR IGNORE INTO files
1013
+ (path, relative_path, size, mtime_ms, sha256, ext, path_segments_json, path_depth, metadata_json, matched_rules_json, status, attempts, created_at, updated_at,
1014
+ original_path, original_relative_path, original_size, original_mtime_ms, original_sha256, original_ext, classification, ingest_variant, source_document_key,
1015
+ derived_path, derived_size, derived_sha256, normalize_strategy, part_index, part_count, page_start, page_end, generated_metadata_json, preflight_json)
1016
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(file.path, file.relativePath, file.size, file.mtimeMs, file.sha256, file.ext, JSON.stringify(file.pathSegments), file.pathDepth, JSON.stringify(evaluated.metadata), JSON.stringify(evaluated.matchedRules), status, now, now, file.originalPath, file.originalRelativePath, file.originalSize, file.originalMtimeMs, file.originalSha256, file.originalExt, file.classification, file.ingestVariant, file.sourceDocumentKey, file.derivedPath ?? null, file.derivedSize ?? null, file.derivedSha256 ?? null, file.normalizeStrategy ?? null, file.partIndex ?? null, file.partCount ?? null, file.pageStart ?? null, file.pageEnd ?? null, JSON.stringify(file.generatedMetadata), JSON.stringify(file.preflight));
1017
+ }
752
1018
  function createBulkSchema(db) {
753
1019
  db.exec(`
754
1020
  CREATE TABLE IF NOT EXISTS jobs (
@@ -758,6 +1024,7 @@ function createBulkSchema(db) {
758
1024
  schema_snapshot_json TEXT,
759
1025
  metadata_map_json TEXT NOT NULL,
760
1026
  dry_run_summary_json TEXT,
1027
+ pipeline_health_json TEXT,
761
1028
  status TEXT NOT NULL,
762
1029
  created_at TEXT NOT NULL,
763
1030
  updated_at TEXT NOT NULL
@@ -802,8 +1069,16 @@ function createBulkSchema(db) {
802
1069
  CREATE INDEX IF NOT EXISTS idx_files_status ON files(status);
803
1070
  CREATE INDEX IF NOT EXISTS idx_files_document_id ON files(document_id);
804
1071
  `);
1072
+ ensureBulkJobColumns(db);
805
1073
  ensureBulkFileColumns(db);
806
1074
  }
1075
+ function ensureBulkJobColumns(db) {
1076
+ const rows = db.prepare("PRAGMA table_info(jobs)").all();
1077
+ const columns = new Set(rows.map((row) => String(row.name)));
1078
+ if (!columns.has("pipeline_health_json")) {
1079
+ db.exec("ALTER TABLE jobs ADD COLUMN pipeline_health_json TEXT;");
1080
+ }
1081
+ }
807
1082
  function ensureBulkFileColumns(db) {
808
1083
  const rows = db.prepare("PRAGMA table_info(files)").all();
809
1084
  const columns = new Set(rows.map((row) => String(row.name)));
@@ -853,6 +1128,9 @@ async function readBulkJob(statePath) {
853
1128
  dryRunSummary: row.dry_run_summary_json
854
1129
  ? JSON.parse(String(row.dry_run_summary_json))
855
1130
  : undefined,
1131
+ pipelineHealth: row.pipeline_health_json
1132
+ ? JSON.parse(String(row.pipeline_health_json))
1133
+ : undefined,
856
1134
  createdAt: String(row.created_at),
857
1135
  updatedAt: String(row.updated_at),
858
1136
  };
@@ -942,7 +1220,8 @@ async function resetInterruptedBulkUploads(statePath) {
942
1220
  const db = await openSqlite(statePath);
943
1221
  const now = new Date().toISOString();
944
1222
  try {
945
- db.prepare("UPDATE files SET status = 'pending', last_error = ?, updated_at = ? WHERE status = 'uploading' AND document_id IS NULL").run("Upload was interrupted before a document id was recorded.", now);
1223
+ createBulkSchema(db);
1224
+ db.prepare("UPDATE files SET status = 'pending', last_error = ?, updated_at = ? WHERE status IN ('uploading', 'planned') AND document_id IS NULL").run("Upload was interrupted before a document id was recorded.", now);
946
1225
  touchJob(db, now);
947
1226
  }
948
1227
  finally {
@@ -1012,6 +1291,32 @@ function rowToBulkFile(row) {
1012
1291
  : {},
1013
1292
  };
1014
1293
  }
1294
+ function bulkRecordToOriginalPlan(row) {
1295
+ const pathSegments = row.originalRelativePath.split("/").filter(Boolean);
1296
+ return {
1297
+ path: row.originalPath,
1298
+ filename: basename(row.originalPath),
1299
+ size: row.originalSize,
1300
+ mtimeMs: row.originalMtimeMs,
1301
+ sha256: row.originalSha256,
1302
+ manifestKey: row.originalRelativePath,
1303
+ relativePath: row.originalRelativePath,
1304
+ ext: row.originalExt,
1305
+ pathSegments,
1306
+ pathDepth: pathSegments.length,
1307
+ originalPath: row.originalPath,
1308
+ originalRelativePath: row.originalRelativePath,
1309
+ originalSize: row.originalSize,
1310
+ originalMtimeMs: row.originalMtimeMs,
1311
+ originalSha256: row.originalSha256,
1312
+ originalExt: row.originalExt,
1313
+ classification: row.classification,
1314
+ ingestVariant: row.ingestVariant,
1315
+ sourceDocumentKey: row.sourceDocumentKey,
1316
+ generatedMetadata: row.generatedMetadata,
1317
+ preflight: row.preflight,
1318
+ };
1319
+ }
1015
1320
  async function resolveBulkStatePath(args, jobIdInput) {
1016
1321
  const explicit = getString(args, "state");
1017
1322
  if (explicit)
@@ -1119,12 +1424,31 @@ async function preflightOneBulkFile(file, options) {
1119
1424
  const docx = await analyzeDocx(file.path).catch((error) => ({
1120
1425
  error: error instanceof Error ? error.message : String(error),
1121
1426
  }));
1427
+ if (isEmptyDocxAnalysis(docx)) {
1428
+ return [
1429
+ decorateBulkPlan(file, "empty", "skipped", {
1430
+ ...docx,
1431
+ reason: "empty_docx",
1432
+ maxUploadBytes: options.maxUploadBytes,
1433
+ }),
1434
+ ];
1435
+ }
1436
+ if (file.size <= DOCX_NORMALIZE_MIN_BYTES) {
1437
+ return [
1438
+ decorateBulkPlan(file, "direct_upload", "direct_upload", {
1439
+ ...docx,
1440
+ maxUploadBytes: options.maxUploadBytes,
1441
+ normalizeThresholdBytes: DOCX_NORMALIZE_MIN_BYTES,
1442
+ }),
1443
+ ];
1444
+ }
1122
1445
  const classification = file.size > options.maxUploadBytes ? "oversize_docx_image_heavy" : "direct_upload";
1123
1446
  if (!options.generateDerived) {
1124
1447
  return [
1125
1448
  decorateBulkPlan(file, classification, "compressed_docx", {
1126
1449
  ...docx,
1127
1450
  maxUploadBytes: options.maxUploadBytes,
1451
+ normalizeThresholdBytes: DOCX_NORMALIZE_MIN_BYTES,
1128
1452
  normalizeStrategy: "docx_image_300dpi_normalize",
1129
1453
  }),
1130
1454
  ];
@@ -1252,15 +1576,16 @@ function bulkInitialStatus(file) {
1252
1576
  return "skipped";
1253
1577
  if (file.ingestVariant === "skipped")
1254
1578
  return "blocked";
1255
- if ((file.ingestVariant === "compressed_docx" || file.ingestVariant === "page_split_pdf") &&
1256
- file.derivedSize === undefined) {
1257
- return "planned";
1258
- }
1259
- if (file.ingestVariant === "compressed_docx")
1579
+ if (file.ingestVariant === "compressed_docx" && file.derivedSize === undefined)
1260
1580
  return "pending";
1261
- if (file.size > 0 && file.size <= Number(file.preflight.maxUploadBytes ?? Infinity))
1581
+ if (file.ingestVariant === "page_split_pdf" && file.derivedSize === undefined)
1262
1582
  return "pending";
1263
- if (file.classification === "direct_upload")
1583
+ if (file.ingestVariant === "compressed_docx") {
1584
+ if (file.derivedSize !== undefined && file.derivedSize <= Number(file.preflight.maxUploadBytes))
1585
+ return "pending";
1586
+ return "blocked";
1587
+ }
1588
+ if (file.size > 0 && file.size <= Number(file.preflight.maxUploadBytes ?? Infinity))
1264
1589
  return "pending";
1265
1590
  if (file.derivedSize !== undefined && file.derivedSize <= Number(file.preflight.maxUploadBytes))
1266
1591
  return "pending";
@@ -1376,13 +1701,61 @@ async function analyzeDocx(path) {
1376
1701
  const entries = readZipEntries(await readFile(path));
1377
1702
  const media = entries.filter((entry) => entry.name.startsWith("word/media/") && !entry.name.endsWith("/"));
1378
1703
  const mediaSizes = media.map((entry) => entry.uncompressedSize);
1704
+ const documentXml = zipEntryText(entries, "word/document.xml") ?? "";
1705
+ const bodyText = extractDocxBodyText(documentXml);
1706
+ const appXml = zipEntryText(entries, "docProps/app.xml") ?? "";
1379
1707
  return {
1380
1708
  mediaCount: media.length,
1381
1709
  mediaTotalBytes: mediaSizes.reduce((sum, size) => sum + size, 0),
1382
1710
  mediaMaxBytes: mediaSizes.reduce((max, size) => Math.max(max, size), 0),
1711
+ textCharacterCount: bodyText.length,
1712
+ paragraphCount: countRegex(documentXml, /<w:p(?:\s|>)/g),
1713
+ drawingCount: countRegex(documentXml, /<w:drawing(?:\s|>)/g),
1714
+ pictCount: countRegex(documentXml, /<w:pict(?:\s|>)/g),
1715
+ appWords: xmlElementNumber(appXml, "Words"),
1716
+ appCharacters: xmlElementNumber(appXml, "Characters"),
1717
+ appParagraphs: xmlElementNumber(appXml, "Paragraphs"),
1383
1718
  zipEntryCount: entries.length,
1384
1719
  };
1385
1720
  }
1721
+ function isEmptyDocxAnalysis(docx) {
1722
+ if (docx.error)
1723
+ return false;
1724
+ const mediaCount = Number(docx.mediaCount ?? 0);
1725
+ const textCharacterCount = Number(docx.textCharacterCount ?? 0);
1726
+ const appWords = Number(docx.appWords ?? 0);
1727
+ return mediaCount === 0 && textCharacterCount === 0 && appWords === 0;
1728
+ }
1729
+ function zipEntryText(entries, name) {
1730
+ const entry = entries.find((candidate) => candidate.name === name);
1731
+ return entry?.data.toString("utf8");
1732
+ }
1733
+ function extractDocxBodyText(xml) {
1734
+ const text = [...xml.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)]
1735
+ .map((match) => decodeXmlText(match[1] ?? ""))
1736
+ .join("");
1737
+ return text.trim();
1738
+ }
1739
+ function countRegex(input, regex) {
1740
+ return (input.match(regex) ?? []).length;
1741
+ }
1742
+ function xmlElementNumber(xml, name) {
1743
+ const match = new RegExp(`<${name}>(\\d+)</${name}>`).exec(xml);
1744
+ if (!match)
1745
+ return undefined;
1746
+ const value = Number(match[1]);
1747
+ return Number.isFinite(value) ? value : undefined;
1748
+ }
1749
+ function decodeXmlText(input) {
1750
+ return input
1751
+ .replace(/&lt;/g, "<")
1752
+ .replace(/&gt;/g, ">")
1753
+ .replace(/&quot;/g, '"')
1754
+ .replace(/&apos;/g, "'")
1755
+ .replace(/&amp;/g, "&")
1756
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
1757
+ .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)));
1758
+ }
1386
1759
  async function createDocxIngestCopy(file, options, docx, classification) {
1387
1760
  const derivedPath = join(options.workDir, "docx", `${safeDerivedName(file.originalRelativePath)}-${file.sha256.slice(0, 12)}.docx`);
1388
1761
  await mkdir(dirname(derivedPath), { recursive: true });
@@ -2247,7 +2620,9 @@ function csvLine(values) {
2247
2620
  return values.map((value) => `"${value.replace(/"/g, '""')}"`).join(",");
2248
2621
  }
2249
2622
  function formatBulkRunSummary(summary) {
2250
- return `Bulk job ${summary.jobId}: completed=${summary.completed} failed=${summary.failed} skipped=${summary.skipped} blocked=${summary.blocked} pending=${summary.pending} inflight=${summary.inflight} waiting_for_index_flags=${summary.waitingForIndexFlags} state=${summary.statePath}\n`;
2623
+ const pressure = summary.pipelineHealth?.pressure ?? "unknown";
2624
+ const action = summary.pipelineHealth?.recommendedAction ?? "continue";
2625
+ return `Bulk job ${summary.jobId}: completed=${summary.completed} failed=${summary.failed} skipped=${summary.skipped} blocked=${summary.blocked} pending=${summary.pending} inflight=${summary.inflight} waiting_for_index_flags=${summary.waitingForIndexFlags} pressure=${pressure} action=${action} state=${summary.statePath}\n`;
2251
2626
  }
2252
2627
  async function uploadWithRetries(input) {
2253
2628
  let lastError;