@pdpp/local-collector 0.12.0 → 0.12.2

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.
@@ -10,6 +10,7 @@ const DEFAULT_QUEUE_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "
10
10
  const LOCAL_COLLECTOR_PACKAGE_NAME = "@pdpp/local-collector";
11
11
  const LOCAL_COLLECTOR_PACKAGE_VERSION_FALLBACK = "0.0.0";
12
12
  const LOCAL_COLLECTOR_PROFILE_DIR_ENV = "PDPP_LOCAL_COLLECTOR_PROFILE_DIR";
13
+ const RECOVER_DEFAULT_MAX_DRAIN_PASSES = 20;
13
14
  const LOCAL_COLLECTOR_PLACEHOLDER_VERSION = "0.0.0";
14
15
  const REPO_ONLY_PACKAGE_SIBLINGS = ["src", "bin", "test", "scripts", "tsconfig.build.json"];
15
16
  function resolveLocalCollectorManifest(startUrl) {
@@ -113,9 +114,10 @@ Subcommands:
113
114
  [--limit <n>]
114
115
  [--apply] Dry-run by default; --apply mutates after a DB backup.
115
116
  recover Resolve the enrolled local profile, recover stalled outbox work,
116
- and run the collector once.
117
+ and drain queued work until clear or bounded.
117
118
  --source-instance-id <id>
118
119
  [--profile <name>] Optional profile name under the collector profile dir.
120
+ [--max-drain-passes <n>] Apply mode runs up to N drain passes (default: ${RECOVER_DEFAULT_MAX_DRAIN_PASSES}).
119
121
  [--apply] Dry-run by default; --apply requeues and runs.
120
122
  prune-sent Delete sent (succeeded) outbox rows to reclaim disk space.
121
123
  [--queue <path>]
@@ -387,7 +389,7 @@ export function buildLocalOutboxDoctor(status, errorSummary) {
387
389
  remediation.push(`${status.outbox.counts.dead_letter} dead-letter row(s) need recovery.${causeHint} ` +
388
390
  "Preview with `pdpp-local-collector recover --source-instance-id <id>`, then apply with " +
389
391
  "`pdpp-local-collector recover --source-instance-id <id> --apply`. The apply step backs up the DB, " +
390
- "prepares failed uploads for retry when present, and runs the collector once.");
392
+ "prepares failed uploads for retry when present, and drains queued work until clear or bounded.");
391
393
  }
392
394
  if (checks.expired_leases === "warn") {
393
395
  remediation.push(`${status.outbox.expired_leases} lease(s) are past expiry — a previous run likely crashed mid-drain. ` +
@@ -699,27 +701,45 @@ function hasDeadLetters(status) {
699
701
  }
700
702
  function recoverDryRunNote(status) {
701
703
  if (hasDeadLetters(status)) {
702
- return (`${status.outbox.counts.dead_letter} failed upload row(s) would be prepared for retry, then the collector would run once. ` +
704
+ return (`${status.outbox.counts.dead_letter} failed upload row(s) would be prepared for retry, then the collector would drain queued work. ` +
703
705
  "Dry run only; re-run with --apply to mutate the local outbox and upload.");
704
706
  }
705
- return ("No failed upload rows are present for this source. The recovery apply step would run the collector once on this host " +
706
- "to refresh state and drain queued work.");
707
+ return ("No failed upload rows are present for this source. The recovery apply step would run the collector on this host " +
708
+ "to refresh state and drain queued work until clear or bounded.");
707
709
  }
708
- function recoverAppliedNote(statusBefore, retry) {
710
+ function outboxOpenWork(status) {
711
+ const counts = status.outbox.counts;
712
+ return counts.dead_letter + counts.leased + counts.pending + counts.retrying;
713
+ }
714
+ function recoverAppliedNote(input) {
715
+ const { attempts, maxPasses, retry, statusAfter, statusBefore, stoppedReason } = input;
709
716
  const retried = retry ? `${retry.requeued} failed upload row(s) were prepared for retry. ` : "";
717
+ const remaining = outboxOpenWork(statusAfter);
718
+ if (stoppedReason === "drained") {
719
+ return `${retried}The collector drained queued work in ${attempts} pass(es).`;
720
+ }
710
721
  if (hasDeadLetters(statusBefore)) {
711
- return `${retried}The collector ran once to upload queued work. Run status again if the dashboard has not refreshed yet.`;
722
+ if (stoppedReason === "max_passes") {
723
+ return `${retried}The collector ran ${attempts} drain pass(es) and ${remaining} queued row(s) remain. Re-run this command to continue; it stopped at the ${maxPasses}-pass safety bound.`;
724
+ }
725
+ return `${retried}The collector ran ${attempts} drain pass(es) and ${remaining} queued row(s) remain. It stopped because another pass did not reduce the backlog.`;
712
726
  }
713
- return "The collector ran once to refresh local state and drain queued work.";
727
+ if (stoppedReason === "max_passes") {
728
+ return `The collector ran ${attempts} drain pass(es) and ${remaining} queued row(s) remain. Re-run this command to continue; it stopped at the ${maxPasses}-pass safety bound.`;
729
+ }
730
+ return `The collector ran ${attempts} drain pass(es) and ${remaining} queued row(s) remain. It stopped because another pass did not reduce the backlog.`;
714
731
  }
715
- export async function recoverLocalCollector(options) {
732
+ export async function recoverLocalCollector(options, deps = {}) {
733
+ const inspectStatus = deps.inspectStatus ?? inspectLocalOutboxStatus;
734
+ const retryDeadLetters = deps.retryDeadLetters ?? retryLocalOutboxDeadLetters;
735
+ const runOnce = deps.runOnce ?? runCollectorOnce;
716
736
  const resolved = resolveRecoveryOptions(options);
717
737
  const sourceInstanceId = resolved.options.sourceInstanceId;
718
738
  if (!sourceInstanceId) {
719
739
  throw new CollectorUsageError("recover requires --source-instance-id <id>");
720
740
  }
721
- const statusBefore = inspectLocalOutboxStatus(resolved.options);
722
- const retryPreview = hasDeadLetters(statusBefore) ? retryLocalOutboxDeadLetters({ ...resolved.options, apply: false }) : null;
741
+ const statusBefore = inspectStatus(resolved.options);
742
+ const retryPreview = hasDeadLetters(statusBefore) ? retryDeadLetters({ ...resolved.options, apply: false }) : null;
723
743
  if (!options.apply) {
724
744
  return {
725
745
  applied: false,
@@ -735,18 +755,52 @@ export async function recoverLocalCollector(options) {
735
755
  status_before: statusBefore,
736
756
  };
737
757
  }
738
- const retryApply = hasDeadLetters(statusBefore) ? retryLocalOutboxDeadLetters({ ...resolved.options, apply: true }) : null;
739
- const run = summarizeRunResultForCli(await runCollectorOnce(resolved.options));
740
- const statusAfter = inspectLocalOutboxStatus(resolved.options);
758
+ const retryApply = hasDeadLetters(statusBefore) ? retryDeadLetters({ ...resolved.options, apply: true }) : null;
759
+ const maxPasses = options.maxDrainPasses ?? RECOVER_DEFAULT_MAX_DRAIN_PASSES;
760
+ const runs = [];
761
+ let statusAfter = inspectStatus(resolved.options);
762
+ let stoppedReason = "drained";
763
+ let previousOpenAfterRun = null;
764
+ for (let attempt = 0; attempt < maxPasses; attempt += 1) {
765
+ const run = summarizeRunResultForCli(await runOnce(resolved.options));
766
+ runs.push(run);
767
+ statusAfter = inspectStatus(resolved.options);
768
+ const openWork = outboxOpenWork(statusAfter);
769
+ const discoveredNewWork = run.recordsQueued > 0 || run.enqueuedBatches > 0;
770
+ if (openWork === 0) {
771
+ stoppedReason = "drained";
772
+ break;
773
+ }
774
+ if (previousOpenAfterRun !== null && openWork >= previousOpenAfterRun && !discoveredNewWork) {
775
+ stoppedReason = "no_progress";
776
+ break;
777
+ }
778
+ previousOpenAfterRun = openWork;
779
+ if (attempt === maxPasses - 1) {
780
+ stoppedReason = "max_passes";
781
+ }
782
+ }
783
+ const latestRun = runs.at(-1) ?? null;
741
784
  return {
742
785
  applied: true,
743
786
  db: statusAfter.db.path ? { exists: statusAfter.db.exists, path: statusAfter.db.path } : { exists: false, path: "" },
787
+ drain_attempts: runs.length,
788
+ drain_stopped_reason: stoppedReason,
744
789
  dry_run: false,
745
- note: recoverAppliedNote(statusBefore, retryApply),
790
+ fully_drained: outboxOpenWork(statusAfter) === 0,
791
+ note: recoverAppliedNote({
792
+ attempts: runs.length,
793
+ maxPasses,
794
+ retry: retryApply,
795
+ statusAfter,
796
+ statusBefore,
797
+ stoppedReason,
798
+ }),
746
799
  object: "local_collector_recovery",
747
800
  profile: { name: resolved.profileName, source: resolved.profileSource },
748
801
  retry_dead_letters: retryApply,
749
- run,
802
+ run: latestRun,
803
+ ...(runs.length > 1 ? { runs } : {}),
750
804
  source_instance_id: sourceInstanceId,
751
805
  status_after: statusAfter,
752
806
  status_before: statusBefore,
@@ -1106,6 +1160,9 @@ function applyOption(options, arg, value) {
1106
1160
  "--keep-count": (next) => {
1107
1161
  options.keepCount = parseNonNegativeInteger("--keep-count", next);
1108
1162
  },
1163
+ "--max-drain-passes": (next) => {
1164
+ options.maxDrainPasses = parsePositiveInteger("--max-drain-passes", next);
1165
+ },
1109
1166
  };
1110
1167
  const set = setters[arg];
1111
1168
  if (!set) {
@@ -1,8 +1,8 @@
1
1
  const COLLECTOR_BUILD_SOURCE_SENTINEL = "source";
2
2
  const COLLECTOR_BUILD_INFO = {
3
- builtAt: "2026-06-18T00:45:12.475Z",
4
- revision: "36131e17f865",
5
- version: "0.12.0",
3
+ builtAt: "2026-06-18T02:10:59.137Z",
4
+ revision: "5fbf3713a9ba",
5
+ version: "0.12.2",
6
6
  };
7
7
  function buildAgentVersion(info = COLLECTOR_BUILD_INFO) {
8
8
  return `${info.version}+${info.revision}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pdpp/local-collector",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Publishable local collector runtime for PDPP: filesystem-class connectors (Claude Code, Codex) plus the device-exporter ingest client.",
5
5
  "type": "module",
6
6
  "private": false,