@trawlme/cli 1.21.0 → 2.0.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.
@@ -348,127 +348,133 @@ export async function pollRunProgress(id, before, opts = {}) {
348
348
  }
349
349
  process.exitCode = 1;
350
350
  }
351
- // list
352
- scraps
353
- .command('list')
354
- .alias('ls')
355
- .description('List all scraps')
356
- .option('--json', 'Output as JSON')
357
- .option('--status <status>', 'Filter by last run status (success|failure|never|running|regression)')
358
- // #88 item 8 no custom parser here (unlike the old `(v) => parseInt(v,
359
- // 10)`): a bad value like "abc" used to silently become NaN, which then
360
- // sailed straight through `Number.isInteger`-less checks and into
361
- // `.slice(0, NaN)` (silently truncates to 0 rows) or `?page=NaN` (silently
362
- // sent to the server) never a usage error. Keeping the raw string here
363
- // lets the validation below mirror `history`'s own --limit check exactly
364
- // (~line 660) and report the actual bad input in the error message.
365
- .option('--limit <n>', 'Show only the first N results')
366
- .option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)')
367
- .action(async (opts, cmd) => {
368
- // Guard: --limit and --page are mutually exclusive
369
- if (opts.limit !== undefined && opts.page !== undefined) {
370
- usageError('--limit and --page are mutually exclusive. Use one or the other.', { json: opts.json });
371
- return;
372
- }
373
- let limit;
374
- if (opts.limit !== undefined) {
375
- limit = Number(opts.limit);
376
- if (!Number.isInteger(limit) || limit <= 0) {
377
- usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
351
+ // list — promoted to a top-level verb (#108, see the AttachOptions comment above)
352
+ export function attachListCommand(parent, attachOpts = {}) {
353
+ return parent
354
+ .command('list', attachOpts)
355
+ .alias('ls')
356
+ .description('List all scraps')
357
+ .option('--json', 'Output as JSON')
358
+ .option('--status <status>', 'Filter by last run status (success|failure|never|running|regression)')
359
+ // #88 item 8 no custom parser here (unlike the old `(v) => parseInt(v,
360
+ // 10)`): a bad value like "abc" used to silently become NaN, which then
361
+ // sailed straight through `Number.isInteger`-less checks and into
362
+ // `.slice(0, NaN)` (silently truncates to 0 rows) or `?page=NaN` (silently
363
+ // sent to the server) never a usage error. Keeping the raw string here
364
+ // lets the validation below mirror `history`'s own --limit check exactly
365
+ // (~line 660) and report the actual bad input in the error message.
366
+ .option('--limit <n>', 'Show only the first N results')
367
+ .option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)')
368
+ .action(async (opts, cmd) => {
369
+ // Guard: --limit and --page are mutually exclusive
370
+ if (opts.limit !== undefined && opts.page !== undefined) {
371
+ usageError('--limit and --page are mutually exclusive. Use one or the other.', { json: opts.json });
378
372
  return;
379
373
  }
380
- }
381
- let page;
382
- if (opts.page !== undefined) {
383
- page = Number(opts.page);
384
- if (!Number.isInteger(page) || page <= 0) {
385
- usageError(`Invalid --page "${opts.page}" (expected a positive integer)`, { json: opts.json });
386
- return;
374
+ let limit;
375
+ if (opts.limit !== undefined) {
376
+ limit = Number(opts.limit);
377
+ if (!Number.isInteger(limit) || limit <= 0) {
378
+ usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
379
+ return;
380
+ }
387
381
  }
388
- }
389
- let data;
390
- try {
391
- data = await oraPromise(async () => {
392
- if (page !== undefined) {
393
- // Single-page mode: explicit page requested, no loop
394
- return api.get(`/api/scraps?perPage=50&page=${page}`);
382
+ let page;
383
+ if (opts.page !== undefined) {
384
+ page = Number(opts.page);
385
+ if (!Number.isInteger(page) || page <= 0) {
386
+ usageError(`Invalid --page "${opts.page}" (expected a positive integer)`, { json: opts.json });
387
+ return;
395
388
  }
396
- // Fetch-all mode: paginate until a page returns < 200 items
397
- const perPage = 200;
398
- let result = [];
399
- let pageNum = 1;
400
- while (true) {
401
- const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${pageNum}`);
402
- result = result.concat(batch);
403
- if (batch.length < perPage)
404
- break;
405
- pageNum++;
389
+ }
390
+ let data;
391
+ try {
392
+ data = await oraPromise(async () => {
393
+ if (page !== undefined) {
394
+ // Single-page mode: explicit page requested, no loop
395
+ return api.get(`/api/scraps?perPage=50&page=${page}`);
396
+ }
397
+ // Fetch-all mode: paginate until a page returns < 200 items
398
+ const perPage = 200;
399
+ let result = [];
400
+ let pageNum = 1;
401
+ while (true) {
402
+ const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${pageNum}`);
403
+ result = result.concat(batch);
404
+ if (batch.length < perPage)
405
+ break;
406
+ pageNum++;
407
+ }
408
+ return result;
409
+ }, 'Fetching scraps…');
410
+ }
411
+ catch (err) {
412
+ // Spinner already failed by oraPromise — report with the scrap-specific
413
+ // prefix kept, but route through the shared classifier so exit code +
414
+ // --json envelope stay consistent with every other command. (#71)
415
+ //
416
+ // This bespoke catch (kept for the "Failed to fetch scraps:" prefix,
417
+ // which the shared reportError() can't add) used to silently swallow
418
+ // --debug: unlike the central index.ts catch, it never printed the raw
419
+ // stack trace. optsWithGlobals() reads --debug off the ROOT command
420
+ // (this leaf has no --debug of its own) so it can honor the flag
421
+ // locally instead. (#86 finding 9)
422
+ const isDebug = Boolean(cmd.optsWithGlobals().debug || process.env['DEBUG']);
423
+ const { exitCode, envelope } = classifyError(err);
424
+ const message = `Failed to fetch scraps: ${envelope.message}`;
425
+ if (isDebug)
426
+ console.error(err);
427
+ if (opts.json) {
428
+ console.log(JSON.stringify({ error: { ...envelope, message } }));
406
429
  }
407
- return result;
408
- }, 'Fetching scraps…');
409
- }
410
- catch (err) {
411
- // Spinner already failed by oraPromise — report with the scrap-specific
412
- // prefix kept, but route through the shared classifier so exit code +
413
- // --json envelope stay consistent with every other command. (#71)
414
- //
415
- // This bespoke catch (kept for the "Failed to fetch scraps:" prefix,
416
- // which the shared reportError() can't add) used to silently swallow
417
- // --debug: unlike the central index.ts catch, it never printed the raw
418
- // stack trace. optsWithGlobals() reads --debug off the ROOT command
419
- // (this leaf has no --debug of its own) so it can honor the flag
420
- // locally instead. (#86 finding 9)
421
- const isDebug = Boolean(cmd.optsWithGlobals().debug || process.env['DEBUG']);
422
- const { exitCode, envelope } = classifyError(err);
423
- const message = `Failed to fetch scraps: ${envelope.message}`;
424
- if (isDebug)
425
- console.error(err);
426
- if (opts.json) {
427
- console.log(JSON.stringify({ error: { ...envelope, message } }));
430
+ else if (!isDebug) {
431
+ console.error(chalk.red(`✗ ${message}`));
432
+ }
433
+ process.exitCode = exitCode;
434
+ return;
428
435
  }
429
- else if (!isDebug) {
430
- console.error(chalk.red(`✗ ${message}`));
436
+ if (opts.status)
437
+ data = data.filter((s) => lastStatus(s) === opts.status);
438
+ const totalMatched = data.length;
439
+ const rows = limit !== undefined ? data.slice(0, limit) : data;
440
+ if (opts.json)
441
+ return json(rows);
442
+ const tableRows = rows.map((s) => ({
443
+ id: s._id,
444
+ title: s.title || '(untitled)',
445
+ cron: s.cron || '—',
446
+ status: statusIcon(lastStatus(s)),
447
+ 'last run': lastRun(s),
448
+ updated: new Date(s.updatedAt).toLocaleDateString(),
449
+ }));
450
+ table(tableRows, ['id', 'title', 'cron', 'status', 'last run', 'updated']);
451
+ // Print footer when --limit truncates
452
+ if (limit !== undefined && rows.length < totalMatched) {
453
+ console.log(chalk.dim(`Showing ${rows.length} of ${totalMatched} — omit --limit to see all`));
431
454
  }
432
- process.exitCode = exitCode;
433
- return;
434
- }
435
- if (opts.status)
436
- data = data.filter((s) => lastStatus(s) === opts.status);
437
- const totalMatched = data.length;
438
- const rows = limit !== undefined ? data.slice(0, limit) : data;
439
- if (opts.json)
440
- return json(rows);
441
- const tableRows = rows.map((s) => ({
442
- id: s._id,
443
- title: s.title || '(untitled)',
444
- cron: s.cron || '—',
445
- status: statusIcon(lastStatus(s)),
446
- 'last run': lastRun(s),
447
- updated: new Date(s.updatedAt).toLocaleDateString(),
448
- }));
449
- table(tableRows, ['id', 'title', 'cron', 'status', 'last run', 'updated']);
450
- // Print footer when --limit truncates
451
- if (limit !== undefined && rows.length < totalMatched) {
452
- console.log(chalk.dim(`Showing ${rows.length} of ${totalMatched} — omit --limit to see all`));
453
- }
454
- });
455
- // get
456
- scraps
457
- .command('get <id>')
458
- .description('Get scrap details')
459
- .option('--json', 'Output as JSON')
460
- .action(async (id, opts) => {
461
- validateObjectId(id);
462
- const data = await api.get(`/api/scraps/${id}`);
463
- if (opts.json)
464
- return json(data);
465
- console.log(chalk.bold(data.title || '(untitled)'));
466
- console.log(chalk.dim(` ID: `) + data._id);
467
- console.log(chalk.dim(` Cron: `) + (data.cron || '—'));
468
- console.log(chalk.dim(` Status: `) + statusIcon(lastStatus(data)));
469
- console.log(chalk.dim(` Last run: `) + lastRun(data));
470
- console.log(chalk.dim(` Updated: `) + new Date(data.updatedAt).toLocaleString());
471
- });
455
+ });
456
+ }
457
+ attachListCommand(scraps, { hidden: true });
458
+ // get — promoted to a top-level verb (#108)
459
+ export function attachGetCommand(parent, attachOpts = {}) {
460
+ return parent
461
+ .command('get <id>', attachOpts)
462
+ .description('Get scrap details')
463
+ .option('--json', 'Output as JSON')
464
+ .action(async (id, opts) => {
465
+ validateObjectId(id);
466
+ const data = await api.get(`/api/scraps/${id}`);
467
+ if (opts.json)
468
+ return json(data);
469
+ console.log(chalk.bold(data.title || '(untitled)'));
470
+ console.log(chalk.dim(` ID: `) + data._id);
471
+ console.log(chalk.dim(` Cron: `) + (data.cron || '—'));
472
+ console.log(chalk.dim(` Status: `) + statusIcon(lastStatus(data)));
473
+ console.log(chalk.dim(` Last run: `) + lastRun(data));
474
+ console.log(chalk.dim(` Updated: `) + new Date(data.updatedAt).toLocaleString());
475
+ });
476
+ }
477
+ attachGetCommand(scraps, { hidden: true });
472
478
  const VALID_TIERS = ['tier0', 'tier1', 'tier2', 'tier3', 'tier4'];
473
479
  /**
474
480
  * #86 findings 4/5 — shared honest-tier renderer for `create --tier` and
@@ -512,7 +518,7 @@ function renderTierOverrideHuman(data) {
512
518
  function warnIfUnconfirmedTier(data, tierWasRequested, id) {
513
519
  if (data._tierOverride || !tierWasRequested)
514
520
  return;
515
- console.error(chalk.yellow(` ⚠ Server did not confirm the tier change (older server) — verify with: trawl scraps get ${id}`));
521
+ console.error(chalk.yellow(` ⚠ Server did not confirm the tier change (older server) — verify with: trawl get ${id}`));
516
522
  }
517
523
  /**
518
524
  * #88 item 3 — the --json machine-readable counterpart to
@@ -709,38 +715,41 @@ scraps
709
715
  if (refused)
710
716
  process.exitCode = 1;
711
717
  });
712
- // run
713
- scraps
714
- .command('run <id>')
715
- .description('Run a scrap')
716
- .option('-w, --watch', 'Show progress after launching (polls — see `trawl scraps trigger --watch`, #91)')
717
- .option('--json', 'Output the raw launch payload as JSON')
718
- .action(async (id, opts) => {
719
- validateObjectId(id);
720
- // #91 P1 / #93 item 1 — captured BEFORE launching so pollRunProgress can
721
- // tell "the run that's about to finish" apart from whatever the last run
722
- // happened to be (including a dedup onto an already-in-flight run).
723
- const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
724
- // #91 P0 GET /api/scraps/load/:id runs the scrap synchronously
725
- // server-side (30-250s); the 30s default was aborting it mid-flight.
726
- const call = () => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS });
727
- // #107 under --json the stdout path stays pure: no spinner channel at
728
- // all, mirroring `trawl fetch`'s own --json handling.
729
- const data = opts.json
730
- ? await call()
731
- : await oraPromise(call, { text: 'Launching scrap…', successText: 'Scrap launched' });
732
- if (opts.json)
733
- json(data);
734
- if (opts.watch) {
735
- // #107 — under --json, pollRunProgress suppresses its own intermediate
736
- // console.log calls and instead emits exactly ONE final NDJSON outcome
737
- // line (+ sets process.exitCode honestly) once the watch reaches a
738
- // terminal status, a timeout, or a persistent poll error (review F1) —
739
- // `run --json --watch` never again exits 0 after dead air regardless
740
- // of what the watched run actually did.
741
- await pollRunProgress(id, beforeRun, { json: opts.json });
742
- }
743
- });
718
+ // run — promoted to a top-level verb (#108)
719
+ export function attachRunCommand(parent, attachOpts = {}) {
720
+ return parent
721
+ .command('run <id>', attachOpts)
722
+ .description('Run a scrap')
723
+ .option('-w, --watch', 'Show progress after launching (polls see `trawl trigger --watch`, #91)')
724
+ .option('--json', 'Output the raw launch payload as JSON')
725
+ .action(async (id, opts) => {
726
+ validateObjectId(id);
727
+ // #91 P1 / #93 item 1 captured BEFORE launching so pollRunProgress can
728
+ // tell "the run that's about to finish" apart from whatever the last run
729
+ // happened to be (including a dedup onto an already-in-flight run).
730
+ const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
731
+ // #91 P0 GET /api/scraps/load/:id runs the scrap synchronously
732
+ // server-side (30-250s); the 30s default was aborting it mid-flight.
733
+ const call = () => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS });
734
+ // #107 under --json the stdout path stays pure: no spinner channel at
735
+ // all, mirroring `trawl create`'s own --json handling.
736
+ const data = opts.json
737
+ ? await call()
738
+ : await oraPromise(call, { text: 'Launching scrap…', successText: 'Scrap launched' });
739
+ if (opts.json)
740
+ json(data);
741
+ if (opts.watch) {
742
+ // #107 under --json, pollRunProgress suppresses its own intermediate
743
+ // console.log calls and instead emits exactly ONE final NDJSON outcome
744
+ // line (+ sets process.exitCode honestly) once the watch reaches a
745
+ // terminal status, a timeout, or a persistent poll error (review F1) —
746
+ // `run --json --watch` never again exits 0 after dead air regardless
747
+ // of what the watched run actually did.
748
+ await pollRunProgress(id, beforeRun, { json: opts.json });
749
+ }
750
+ });
751
+ }
752
+ attachRunCommand(scraps, { hidden: true });
744
753
  // #70 — render an items array either as a table summary or --json. Shared by
745
754
  // both the default (persisted read) and --fresh (live execute) paths of `data`.
746
755
  function renderScrapItems(items, asJson) {
@@ -774,228 +783,237 @@ function reportDataState(message, exitCode, kind, wantsJson) {
774
783
  }
775
784
  process.exitCode = exitCode;
776
785
  }
777
- // data
778
- scraps
779
- .command('data <id>')
780
- .description('Get scrap data (last persisted run — read-only, no quota). Use --fresh to launch a new run instead.')
781
- .option('--json', 'Output as JSON')
782
- .option('--errors', 'Show failure diagnostics when the last run failed')
783
- .option('--fresh', 'Launch a fresh run instead of reading the last persisted payload (consumes execute quota, same as `scraps run`)')
784
- .action(async (id, opts) => {
785
- validateObjectId(id);
786
- // --errors: fetch full run + fix detail via fetchRunAndFix (DRY with doctor).
787
- // Read-only: GET /api/scraps/:id + GET /api/historys/:hid, no quota, no run lock.
788
- if (opts.errors) {
789
- const result = await fetchRunAndFix(id);
790
- if (!result) {
791
- // #88 item 7 — unified no-runs shape with `doctor --json`: a bare
792
- // `null` was indistinguishable from any other absent-payload state
793
- // (a scrap CAN legitimately have a null-ish result elsewhere); an
794
- // explicit `{status:"no_runs"}` object is unambiguous everywhere.
795
- if (opts.json) {
796
- json({ status: 'no_runs' });
786
+ // data — promoted to a top-level verb (#108)
787
+ export function attachDataCommand(parent, attachOpts = {}) {
788
+ return parent
789
+ .command('data <id>', attachOpts)
790
+ .description('Get scrap data (last persisted run — read-only, no quota). Use --fresh to launch a new run instead.')
791
+ .option('--json', 'Output as JSON')
792
+ .option('--errors', 'Show failure diagnostics when the last run failed')
793
+ .option('--fresh', 'Launch a fresh run instead of reading the last persisted payload (consumes execute quota, same as `run`)')
794
+ .action(async (id, opts) => {
795
+ validateObjectId(id);
796
+ // --errors: fetch full run + fix detail via fetchRunAndFix (DRY with doctor).
797
+ // Read-only: GET /api/scraps/:id + GET /api/historys/:hid, no quota, no run lock.
798
+ if (opts.errors) {
799
+ const result = await fetchRunAndFix(id);
800
+ if (!result) {
801
+ // #88 item 7 unified no-runs shape with `doctor --json`: a bare
802
+ // `null` was indistinguishable from any other absent-payload state
803
+ // (a scrap CAN legitimately have a null-ish result elsewhere); an
804
+ // explicit `{status:"no_runs"}` object is unambiguous everywhere.
805
+ if (opts.json) {
806
+ json({ status: 'no_runs' });
807
+ return;
808
+ }
809
+ console.log(chalk.dim('No runs yet.'));
797
810
  return;
798
811
  }
799
- console.log(chalk.dim('No runs yet.'));
812
+ // --json is honored for BOTH outcomes (success or failure) — an agent
813
+ // parsing `data --errors --json` must always get the flat run object,
814
+ // never prose gated behind a status check. (#71 finding 13)
815
+ if (opts.json)
816
+ return json(pickRun(result.run));
817
+ if (result.run.status === true) {
818
+ console.log(chalk.green('✓ Last run succeeded. No errors to show.'));
819
+ return;
820
+ }
821
+ console.log(formatDoctor(result.scrap.title, result.run, result.fix, id));
800
822
  return;
801
823
  }
802
- // --json is honored for BOTH outcomes (success or failure) an agent
803
- // parsing `data --errors --json` must always get the flat run object,
804
- // never prose gated behind a status check. (#71 finding 13)
805
- if (opts.json)
806
- return json(pickRun(result.run));
807
- if (result.run.status === true) {
808
- console.log(chalk.green('✓ Last run succeeded. No errors to show.'));
824
+ // #70 — --fresh is the explicit opt-in for a LIVE run. This is what the
825
+ // default path used to do silently: GET /api/scraps/load/:id executes a
826
+ // fresh scrap run server-side (requireQuota('scraps','execute') + a
827
+ // distributed run lock — trawl_node modules/scraps/routes/scraps.routes.js),
828
+ // burning execute quota and 429ing if a run is already in flight. A user
829
+ // or agent "just reading data" must never trigger that by accident.
830
+ if (opts.fresh) {
831
+ // #91 P0 — same long-run endpoint as `run` (30-250s server-side);
832
+ // the 30s default was aborting it mid-flight.
833
+ const loaded = await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
834
+ text: 'Launching a fresh scrap run (consumes execute quota)…',
835
+ successText: 'Fresh run complete',
836
+ });
837
+ const items = loaded?.result?.data;
838
+ if (!Array.isArray(items)) {
839
+ // --json always returns an array from `data` — [] is the honest
840
+ // "no items" signal instead of prose breaking JSON parsing. (#71)
841
+ if (opts.json) {
842
+ json([]);
843
+ return;
844
+ }
845
+ console.log(chalk.dim('No data yet. Run the scrap first.'));
846
+ return;
847
+ }
848
+ // #93 item 2 — --fresh used to render items without ever checking for a
849
+ // regression, unlike the persisted `data` path below (#88 item 2). The
850
+ // load() response's OWN embedded `scrap.history[0]` can't be trusted for
851
+ // this (see the ScrapLoadResult comment above): it may be the previous
852
+ // run's row, and even the right row never carries the regression flip.
853
+ // `--fresh` runs synchronously to completion server-side though — by the
854
+ // time this call returns, node has already awaited the regression patch
855
+ // — so a fresh GET /api/scraps/:id (the SAME read the persisted path
856
+ // below already trusts) reliably observes the finalized DB state.
857
+ // Best-effort: never fail --fresh's real output over this side check,
858
+ // and never gate on it — the items returned ARE this run's real,
859
+ // synchronously-computed data regardless of what this check finds.
860
+ try {
861
+ const fresh = await api.get(`/api/scraps/${id}`);
862
+ if (fresh.history?.[0]?.statusDetail === 'regression') {
863
+ console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} — see: trawl scraps doctor ${id}`));
864
+ }
865
+ }
866
+ catch {
867
+ // best-effort — the fresh run's items are still valid without this check
868
+ }
869
+ renderScrapItems(items, opts.json);
809
870
  return;
810
871
  }
811
- console.log(formatDoctor(result.scrap.title, result.run, result.fix, id));
812
- return;
813
- }
814
- // #70 — --fresh is the explicit opt-in for a LIVE run. This is what the
815
- // default path used to do silently: GET /api/scraps/load/:id executes a
816
- // fresh scrap run server-side (requireQuota('scraps','execute') + a
817
- // distributed run lock trawl_node modules/scraps/routes/scraps.routes.js),
818
- // burning execute quota and 429ing if a run is already in flight. A user
819
- // or agent "just reading data" must never trigger that by accident.
820
- if (opts.fresh) {
821
- // #91 P0 same long-run endpoint as `scraps run` (30-250s server-side);
822
- // the 30s default was aborting it mid-flight.
823
- const loaded = await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
824
- text: 'Launching a fresh scrap run (consumes execute quota)…',
825
- successText: 'Fresh run complete',
826
- });
827
- const items = loaded?.result?.data;
872
+ // Default: read the last PERSISTED run payload — no quota, no run lock.
873
+ // History.data (trawl_node modules/historys/services/historys.service.js
874
+ // `update()`) is a JSON-stringified clone of the worker result, so it
875
+ // carries the same `.data` items array the live load endpoint returns
876
+ // it's just pulled from the most recent history row instead of a fresh
877
+ // run. Retention keeps this only for the newest row per (scrap, status)
878
+ // bucket (config.trawl.keepData, default 1); older rows null it out.
879
+ //
880
+ // #86 finding 6 [] is reserved for a GENUINE zero-item successful run.
881
+ // Every other outcome below is an honest error envelope instead: never
882
+ // run (not_found/4), last run failed (1), or the payload aged out of
883
+ // retention (not_found/4) all used to collapse into the same silent [].
884
+ const scrap = await api.get(`/api/scraps/${id}`);
885
+ const last = scrap.history?.[0];
886
+ if (!last?._id) {
887
+ reportDataState(`Scrap ${id} has never run. Run it first (trawl run ${id}) or pass --fresh to launch one now.`, 4, 'not_found', opts.json);
888
+ return;
889
+ }
890
+ // #88 item 1 — status:null is an IN-FLIGHT run (node persists
891
+ // {status:null, statusDetail:null, inFlight:true} the moment a run
892
+ // starts, and only flips status/statusDetail once it finishes). That is
893
+ // neither "never run" nor "the last run failed" — a caller reading data
894
+ // mid-run needs an honest "wait" signal. Never suggest --fresh here: a
895
+ // run already holds the server-side distributed lock, so --fresh would
896
+ // just 429 against it.
897
+ if (last.status === null) {
898
+ reportDataState(`Run in progress for ${id} — retry shortly.`, 1, 'in_progress', opts.json);
899
+ return;
900
+ }
901
+ // #86 review — node persists status=false for a GENUINE zero-item run
902
+ // too (historys schema: status boolean|null + statusDetail
903
+ // success/error/empty/regression; a zero-item run is status=false +
904
+ // statusDetail='empty', and the embedded history rows from GET
905
+ // /api/scraps/:id include statusDetail via the repository populate
906
+ // select). An 'empty' run is the one case [] is FOR — only a real
907
+ // failure (error/unknown detail) gets the run_failed envelope.
908
+ //
909
+ // #88 item 2 — statusDetail='regression' is ALSO status=false (an async
910
+ // patch flips it after item count dropped vs baseline), but the row's
911
+ // `data` still holds REAL, non-empty items — the write that persisted
912
+ // them succeeded before the regression was even detected. Treating it as
913
+ // run_failed would hide genuine data behind a false negative.
914
+ const isEmptyRun = last.status === false && last.statusDetail === 'empty';
915
+ const isRegression = last.status === false && last.statusDetail === 'regression';
916
+ if (last.status === false && !isEmptyRun && !isRegression) {
917
+ reportDataState(`Last run failed — see: trawl data ${id} --errors`, 1, 'run_failed', opts.json);
918
+ return;
919
+ }
920
+ const detail = await api.get(`/api/historys/${last._id}`);
921
+ let items;
922
+ if (typeof detail?.data === 'string' && detail.data) {
923
+ try {
924
+ items = JSON.parse(detail.data)?.data;
925
+ }
926
+ catch {
927
+ items = undefined;
928
+ }
929
+ }
828
930
  if (!Array.isArray(items)) {
829
- // --json always returns an array from `data` — [] is the honest
830
- // "no items" signal instead of prose breaking JSON parsing. (#71)
831
- if (opts.json) {
832
- json([]);
931
+ if (isEmptyRun) {
932
+ // A genuine zero-item run whose payload is '[]' or absent — both are
933
+ // the SAME honest answer: no items, exit 0. Never the retention
934
+ // message (nothing aged out; there was nothing to persist).
935
+ renderScrapItems([], opts.json);
833
936
  return;
834
937
  }
835
- console.log(chalk.dim('No data yet. Run the scrap first.'));
938
+ // #88 item 2 a regression row whose payload aged out of retention has
939
+ // nothing left to show either; fall through to the SAME honest
940
+ // aged-out envelope a normal successful row would get (never fabricate
941
+ // items, never silently succeed).
942
+ reportDataState(`No persisted data for the last run of ${id} — it aged out of retention. Pass --fresh to launch a new run.`, 4, 'not_found', opts.json);
836
943
  return;
837
944
  }
838
- // #93 item 2 — --fresh used to render items without ever checking for a
839
- // regression, unlike the persisted `data` path below (#88 item 2). The
840
- // load() response's OWN embedded `scrap.history[0]` can't be trusted for
841
- // this (see the ScrapLoadResult comment above): it may be the previous
842
- // run's row, and even the right row never carries the regression flip.
843
- // `--fresh` runs synchronously to completion server-side thoughby the
844
- // time this call returns, node has already awaited the regression patch
845
- // — so a fresh GET /api/scraps/:id (the SAME read the persisted path
846
- // below already trusts) reliably observes the finalized DB state.
847
- // Best-effort: never fail --fresh's real output over this side check,
848
- // and never gate on it — the items returned ARE this run's real,
849
- // synchronously-computed data regardless of what this check finds.
850
- try {
851
- const fresh = await api.get(`/api/scraps/${id}`);
852
- if (fresh.history?.[0]?.statusDetail === 'regression') {
853
- console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} — see: trawl scraps doctor ${id}`));
854
- }
855
- }
856
- catch {
857
- // best-effort — the fresh run's items are still valid without this check
945
+ // #88 item 2 — a regression row's items are REAL (the write succeeded
946
+ // before the async patch flagged the drop) return them on stdout
947
+ // (exit 0, both modes) with an honest stderr warning pointing at the
948
+ // diagnostic command, instead of hiding genuine data behind run_failed.
949
+ if (isRegression) {
950
+ console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} see: trawl scraps doctor ${id}`));
858
951
  }
859
952
  renderScrapItems(items, opts.json);
860
- return;
861
- }
862
- // Default: read the last PERSISTED run payload — no quota, no run lock.
863
- // History.data (trawl_node modules/historys/services/historys.service.js
864
- // `update()`) is a JSON-stringified clone of the worker result, so it
865
- // carries the same `.data` items array the live load endpoint returns —
866
- // it's just pulled from the most recent history row instead of a fresh
867
- // run. Retention keeps this only for the newest row per (scrap, status)
868
- // bucket (config.trawl.keepData, default 1); older rows null it out.
869
- //
870
- // #86 finding 6 — [] is reserved for a GENUINE zero-item successful run.
871
- // Every other outcome below is an honest error envelope instead: never
872
- // run (not_found/4), last run failed (1), or the payload aged out of
873
- // retention (not_found/4) all used to collapse into the same silent [].
874
- const scrap = await api.get(`/api/scraps/${id}`);
875
- const last = scrap.history?.[0];
876
- if (!last?._id) {
877
- reportDataState(`Scrap ${id} has never run. Run it first (trawl scraps run ${id}) or pass --fresh to launch one now.`, 4, 'not_found', opts.json);
878
- return;
879
- }
880
- // #88 item 1 — status:null is an IN-FLIGHT run (node persists
881
- // {status:null, statusDetail:null, inFlight:true} the moment a run
882
- // starts, and only flips status/statusDetail once it finishes). That is
883
- // neither "never run" nor "the last run failed" — a caller reading data
884
- // mid-run needs an honest "wait" signal. Never suggest --fresh here: a
885
- // run already holds the server-side distributed lock, so --fresh would
886
- // just 429 against it.
887
- if (last.status === null) {
888
- reportDataState(`Run in progress for ${id} — retry shortly.`, 1, 'in_progress', opts.json);
889
- return;
890
- }
891
- // #86 review — node persists status=false for a GENUINE zero-item run
892
- // too (historys schema: status boolean|null + statusDetail
893
- // success/error/empty/regression; a zero-item run is status=false +
894
- // statusDetail='empty', and the embedded history rows from GET
895
- // /api/scraps/:id include statusDetail via the repository populate
896
- // select). An 'empty' run is the one case [] is FOR — only a real
897
- // failure (error/unknown detail) gets the run_failed envelope.
898
- //
899
- // #88 item 2 — statusDetail='regression' is ALSO status=false (an async
900
- // patch flips it after item count dropped vs baseline), but the row's
901
- // `data` still holds REAL, non-empty items — the write that persisted
902
- // them succeeded before the regression was even detected. Treating it as
903
- // run_failed would hide genuine data behind a false negative.
904
- const isEmptyRun = last.status === false && last.statusDetail === 'empty';
905
- const isRegression = last.status === false && last.statusDetail === 'regression';
906
- if (last.status === false && !isEmptyRun && !isRegression) {
907
- reportDataState(`Last run failed — see: trawl scraps data ${id} --errors`, 1, 'run_failed', opts.json);
908
- return;
909
- }
910
- const detail = await api.get(`/api/historys/${last._id}`);
911
- let items;
912
- if (typeof detail?.data === 'string' && detail.data) {
913
- try {
914
- items = JSON.parse(detail.data)?.data;
953
+ });
954
+ }
955
+ attachDataCommand(scraps, { hidden: true });
956
+ // history — list past runs for a scrap — promoted to a top-level verb (#108)
957
+ export function attachHistoryCommand(parent, attachOpts = {}) {
958
+ return parent
959
+ .command('history <id>', attachOpts)
960
+ .description('List past runs for a scrap (newest first)')
961
+ .option('--json', 'Output as JSON')
962
+ .option('-n, --limit <n>', 'Max runs to show', '20')
963
+ .action(async (id, opts) => {
964
+ validateObjectId(id);
965
+ const limit = Number(opts.limit);
966
+ if (!Number.isInteger(limit) || limit < 1) {
967
+ usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
968
+ return;
915
969
  }
916
- catch {
917
- items = undefined;
970
+ const scrap = await api.get(`/api/scraps/${id}`);
971
+ const runs = (scrap.history ?? []).slice(0, limit).map((h) => ({
972
+ hid: h._id,
973
+ status: h.statusDetail ?? null,
974
+ time: h.time ?? null,
975
+ tier: h.proxyTier ?? null,
976
+ failureKind: h.failureKind ?? null,
977
+ blockType: h.blockType ?? null,
978
+ createdAt: h.createdAt ?? null,
979
+ }));
980
+ if (opts.json) {
981
+ json(runs);
982
+ return;
918
983
  }
919
- }
920
- if (!Array.isArray(items)) {
921
- if (isEmptyRun) {
922
- // A genuine zero-item run whose payload is '[]' or absent — both are
923
- // the SAME honest answer: no items, exit 0. Never the retention
924
- // message (nothing aged out; there was nothing to persist).
925
- renderScrapItems([], opts.json);
984
+ table(runs, ['hid', 'status', 'time', 'tier', 'failureKind', 'blockType', 'createdAt']);
985
+ });
986
+ }
987
+ attachHistoryCommand(scraps, { hidden: true });
988
+ // run-info single-run detail by history id promoted to a top-level verb (#108)
989
+ export function attachRunInfoCommand(parent, attachOpts = {}) {
990
+ return parent
991
+ .command('run-info <hid>', attachOpts)
992
+ .description('Show details of a single run (status, tier, failureKind, error)')
993
+ .option('--json', 'Output as JSON')
994
+ .action(async (hid, opts) => {
995
+ validateObjectId(hid);
996
+ const h = await api.get(`/api/historys/${hid}`);
997
+ const info = {
998
+ hid,
999
+ status: h.statusDetail ?? null,
1000
+ time: h.time ?? null,
1001
+ tier: h.proxyTier ?? null,
1002
+ failureKind: h.failureKind ?? null,
1003
+ blockType: h.blockType ?? null,
1004
+ errorMessage: h.errorSnapshot?.errorMessage ?? null,
1005
+ selector: h.errorSnapshot?.selector ?? null,
1006
+ emptyContext: h.errorSnapshot?.emptyContext ?? null,
1007
+ createdAt: h.createdAt ?? null,
1008
+ };
1009
+ if (opts.json) {
1010
+ json(info);
926
1011
  return;
927
1012
  }
928
- // #88 item 2 a regression row whose payload aged out of retention has
929
- // nothing left to show either; fall through to the SAME honest
930
- // aged-out envelope a normal successful row would get (never fabricate
931
- // items, never silently succeed).
932
- reportDataState(`No persisted data for the last run of ${id} — it aged out of retention. Pass --fresh to launch a new run.`, 4, 'not_found', opts.json);
933
- return;
934
- }
935
- // #88 item 2 — a regression row's items are REAL (the write succeeded
936
- // before the async patch flagged the drop) — return them on stdout
937
- // (exit 0, both modes) with an honest stderr warning pointing at the
938
- // diagnostic command, instead of hiding genuine data behind run_failed.
939
- if (isRegression) {
940
- console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} — see: trawl scraps doctor ${id}`));
941
- }
942
- renderScrapItems(items, opts.json);
943
- });
944
- // history — list past runs for a scrap
945
- scraps
946
- .command('history <id>')
947
- .description('List past runs for a scrap (newest first)')
948
- .option('--json', 'Output as JSON')
949
- .option('-n, --limit <n>', 'Max runs to show', '20')
950
- .action(async (id, opts) => {
951
- validateObjectId(id);
952
- const limit = Number(opts.limit);
953
- if (!Number.isInteger(limit) || limit < 1) {
954
- usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
955
- return;
956
- }
957
- const scrap = await api.get(`/api/scraps/${id}`);
958
- const runs = (scrap.history ?? []).slice(0, limit).map((h) => ({
959
- hid: h._id,
960
- status: h.statusDetail ?? null,
961
- time: h.time ?? null,
962
- tier: h.proxyTier ?? null,
963
- failureKind: h.failureKind ?? null,
964
- blockType: h.blockType ?? null,
965
- createdAt: h.createdAt ?? null,
966
- }));
967
- if (opts.json) {
968
- json(runs);
969
- return;
970
- }
971
- table(runs, ['hid', 'status', 'time', 'tier', 'failureKind', 'blockType', 'createdAt']);
972
- });
973
- // run-info — single-run detail by history id
974
- scraps
975
- .command('run-info <hid>')
976
- .description('Show details of a single run (status, tier, failureKind, error)')
977
- .option('--json', 'Output as JSON')
978
- .action(async (hid, opts) => {
979
- validateObjectId(hid);
980
- const h = await api.get(`/api/historys/${hid}`);
981
- const info = {
982
- hid,
983
- status: h.statusDetail ?? null,
984
- time: h.time ?? null,
985
- tier: h.proxyTier ?? null,
986
- failureKind: h.failureKind ?? null,
987
- blockType: h.blockType ?? null,
988
- errorMessage: h.errorSnapshot?.errorMessage ?? null,
989
- selector: h.errorSnapshot?.selector ?? null,
990
- emptyContext: h.errorSnapshot?.emptyContext ?? null,
991
- createdAt: h.createdAt ?? null,
992
- };
993
- if (opts.json) {
994
- json(info);
995
- return;
996
- }
997
- table([info], ['hid', 'status', 'time', 'tier', 'failureKind', 'blockType', 'errorMessage', 'selector', 'emptyContext', 'createdAt']);
998
- });
1013
+ table([info], ['hid', 'status', 'time', 'tier', 'failureKind', 'blockType', 'errorMessage', 'selector', 'emptyContext', 'createdAt']);
1014
+ });
1015
+ }
1016
+ attachRunInfoCommand(scraps, { hidden: true });
999
1017
  // delete
1000
1018
  scraps
1001
1019
  .command('delete <id>')
@@ -1088,46 +1106,49 @@ scraps
1088
1106
  validateObjectId(id);
1089
1107
  await watchActivities(id, opts.json);
1090
1108
  });
1091
- // trigger
1092
- scraps
1093
- .command('trigger <id>')
1094
- .description('Launch a scrap as a background worker (returns immediately)')
1095
- .option('-w, --watch', 'Poll for progress after triggering (#91 — the default async run happens in a separate cron pod; activities SSE never reaches it)')
1096
- .option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
1097
- .option('--json', 'Output the raw trigger payload as JSON')
1098
- .action(async (id, opts) => {
1099
- validateObjectId(id);
1100
- // #91 P1 / #93 item 1 — captured BEFORE triggering so pollRunProgress can
1101
- // tell "the run we just triggered" apart from whatever the last run
1102
- // happened to be. This is the dedup-prone path: `trigger`'s method:'worker'
1103
- // collapses onto an already-pending/running worker job for the same scrap
1104
- // (ScrapJobsService, LIVE_STATUSES) instead of creating a new history row
1105
- // captureBeforeRunState records that so pollRunProgress can still track it.
1106
- const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
1107
- // #50 default async: the backend (#1313) kicks off the run and returns a
1108
- // 'queued' envelope immediately instead of holding the connection for the
1109
- // whole run. --wait restores the old synchronous round-trip.
1110
- const path = opts.wait ? `/api/scraps/worker/${id}` : `/api/scraps/worker/${id}?wait=false`;
1111
- // #91 P0 — the synchronous --wait branch runs the scrap server-side
1112
- // (30-250s), same as `scraps run`; the 30s default was aborting it
1113
- // mid-flight. The async (default) POST returns almost immediately, so it
1114
- // keeps the 30s default.
1115
- const call = () => (opts.wait ? api.post(path, undefined, { timeoutMs: LONG_RUN_TIMEOUT_MS }) : api.post(path));
1116
- // #107 under --json the stdout path stays pure: no spinner channel.
1117
- const data = opts.json
1118
- ? await call()
1119
- : await oraPromise(call, {
1120
- text: opts.wait ? 'Running worker…' : 'Triggering worker…',
1121
- successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
1122
- });
1123
- if (opts.json)
1124
- json(data);
1125
- // #107 — see the matching comment on `run`'s --watch call above (review
1126
- // F1): honest final NDJSON line + exit code under --json, human mode
1127
- // gets the same honest exit code too.
1128
- if (opts.watch)
1129
- await pollRunProgress(id, beforeRun, { json: opts.json });
1130
- });
1109
+ // trigger — promoted to a top-level verb (#108)
1110
+ export function attachTriggerCommand(parent, attachOpts = {}) {
1111
+ return parent
1112
+ .command('trigger <id>', attachOpts)
1113
+ .description('Launch a scrap as a background worker (returns immediately)')
1114
+ .option('-w, --watch', 'Poll for progress after triggering (#91 — the default async run happens in a separate cron pod; activities SSE never reaches it)')
1115
+ .option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
1116
+ .option('--json', 'Output the raw trigger payload as JSON')
1117
+ .action(async (id, opts) => {
1118
+ validateObjectId(id);
1119
+ // #91 P1 / #93 item 1 captured BEFORE triggering so pollRunProgress can
1120
+ // tell "the run we just triggered" apart from whatever the last run
1121
+ // happened to be. This is the dedup-prone path: `trigger`'s method:'worker'
1122
+ // collapses onto an already-pending/running worker job for the same scrap
1123
+ // (ScrapJobsService, LIVE_STATUSES) instead of creating a new history row —
1124
+ // captureBeforeRunState records that so pollRunProgress can still track it.
1125
+ const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
1126
+ // #50 default async: the backend (#1313) kicks off the run and returns a
1127
+ // 'queued' envelope immediately instead of holding the connection for the
1128
+ // whole run. --wait restores the old synchronous round-trip.
1129
+ const path = opts.wait ? `/api/scraps/worker/${id}` : `/api/scraps/worker/${id}?wait=false`;
1130
+ // #91 P0 the synchronous --wait branch runs the scrap server-side
1131
+ // (30-250s), same as `run`; the 30s default was aborting it
1132
+ // mid-flight. The async (default) POST returns almost immediately, so it
1133
+ // keeps the 30s default.
1134
+ const call = () => (opts.wait ? api.post(path, undefined, { timeoutMs: LONG_RUN_TIMEOUT_MS }) : api.post(path));
1135
+ // #107 under --json the stdout path stays pure: no spinner channel.
1136
+ const data = opts.json
1137
+ ? await call()
1138
+ : await oraPromise(call, {
1139
+ text: opts.wait ? 'Running worker…' : 'Triggering worker…',
1140
+ successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
1141
+ });
1142
+ if (opts.json)
1143
+ json(data);
1144
+ // #107 see the matching comment on `run`'s --watch call above (review
1145
+ // F1): honest final NDJSON line + exit code under --json, human mode
1146
+ // gets the same honest exit code too.
1147
+ if (opts.watch)
1148
+ await pollRunProgress(id, beforeRun, { json: opts.json });
1149
+ });
1150
+ }
1151
+ attachTriggerCommand(scraps, { hidden: true });
1131
1152
  // account subcommand group
1132
1153
  const account = scraps
1133
1154
  .command('account')