@trawlme/cli 1.21.0 → 1.22.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/README.md +44 -35
- package/dist/commands/scraps.d.ts +29 -0
- package/dist/commands/scraps.js +413 -392
- package/dist/index.d.ts +8 -0
- package/dist/index.js +45 -7
- package/docs/agent-quickstart.md +103 -0
- package/package.json +2 -1
package/dist/commands/scraps.js
CHANGED
|
@@ -348,127 +348,133 @@ export async function pollRunProgress(id, before, opts = {}) {
|
|
|
348
348
|
}
|
|
349
349
|
process.exitCode = 1;
|
|
350
350
|
}
|
|
351
|
-
// list
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
id
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
json
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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 fetch`'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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
json
|
|
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
|
-
|
|
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
|
-
// --
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
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
|
-
// #
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
917
|
-
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
json
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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')
|