@trawlme/cli 1.18.4 → 1.18.5

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.
@@ -1,5 +1,11 @@
1
1
  import { Command } from 'commander';
2
2
  export declare const scraps: Command;
3
+ /** The top-of-history snapshot pollRunProgress needs to identify which run
4
+ * it's watching — see captureBeforeRunState. */
5
+ interface BeforeRunState {
6
+ id?: string;
7
+ alreadyInFlight: boolean;
8
+ }
3
9
  /**
4
10
  * #91 P1 — replaces "await the run to completion, THEN open the activities
5
11
  * SSE stream" (which showed NOTHING: the activities SSE
@@ -20,8 +26,21 @@ export declare const scraps: Command;
20
26
  * - GET /api/scraps/:id — history[0].status/statusDetail, the SAME
21
27
  * terminal-status signal `lastStatus()` above already trusts (status:null
22
28
  * === in flight, #88 item 1) to know when the run is done.
29
+ *
30
+ * #93 item 1 — dedup-race fix. "Is this the run we're watching?" used to be
31
+ * a single check: `history[0]._id !== beforeHistoryId`. That's wrong when
32
+ * `before.alreadyInFlight` is true (a `trigger` call deduped onto a worker
33
+ * job that was ALREADY pending/running at capture time): the top row IS the
34
+ * run we're watching, but its `_id` never changes, so the old guard never
35
+ * released and the poll ran the full timeout to a false "Timed out". The run
36
+ * we're watching is now EITHER a brand-new id (fresh trigger, the common
37
+ * case) OR the same id that was already in-flight (status:null) at capture
38
+ * (the dedup case) — a same-id row that was already TERMINAL at capture is
39
+ * neither, and must not be latched onto as "done" (it's just the previous
40
+ * run, still sitting there until a genuinely new run supersedes it).
23
41
  */
24
- export declare function pollRunProgress(id: string, beforeHistoryId: string | undefined, opts?: {
42
+ export declare function pollRunProgress(id: string, before: BeforeRunState | undefined, opts?: {
25
43
  intervalMs?: number;
26
44
  timeoutMs?: number;
27
45
  }): Promise<void>;
46
+ export {};
@@ -83,21 +83,30 @@ async function watchActivities(id) {
83
83
  }
84
84
  }
85
85
  /**
86
- * #91 P1 — snapshot the current top history entry BEFORE triggering a run,
87
- * so pollRunProgress can later detect the NEW run once the server pushes it
88
- * (node persists a fresh {status:null, inFlight:true} row the moment a run
89
- * starts see HistorysService.create in trawl_node so a changed
90
- * history[0]._id is an honest "the new run has begun" signal). Best-effort:
91
- * a failed lookup just means the poll treats the very first history row it
92
- * sees as new, which is still correct for a scrap that has never run.
86
+ * #91 P1 / #93 item 1 — snapshot the current top history entry BEFORE
87
+ * triggering a run, so pollRunProgress can later tell "the run we just
88
+ * launched" apart from whatever the last run happened to be. Node persists a
89
+ * fresh {status:null, inFlight:true} row the moment a run starts (see
90
+ * HistorysService.create in trawl_node), so a changed history[0]._id is
91
+ * normally an honest "the new run has begun" signal EXCEPT `trigger`
92
+ * (method:'worker') dedups onto an already-pending/running worker job
93
+ * (ScrapJobsService, LIVE_STATUSES) instead of creating a new row: the top
94
+ * row's `_id` never changes even though this launch IS that run. Recording
95
+ * `alreadyInFlight` (status===null at capture time) lets pollRunProgress
96
+ * recognize that dedup case too, instead of waiting forever for an `_id`
97
+ * that will never arrive (#93 item 1).
98
+ *
99
+ * Best-effort: a failed lookup falls back to `{alreadyInFlight:false}`,
100
+ * which is still correct for a scrap that has never run (id undefined).
93
101
  */
94
- async function captureBeforeHistoryId(id) {
102
+ async function captureBeforeRunState(id) {
95
103
  try {
96
104
  const scrap = await api.get(`/api/scraps/${id}`);
97
- return scrap.history?.[0]?._id;
105
+ const top = scrap.history?.[0];
106
+ return { id: top?._id, alreadyInFlight: top?.status === null };
98
107
  }
99
108
  catch {
100
- return undefined;
109
+ return { id: undefined, alreadyInFlight: false };
101
110
  }
102
111
  }
103
112
  function sleep(ms) {
@@ -127,10 +136,24 @@ const POLL_TIMEOUT_MS = LONG_RUN_TIMEOUT_MS;
127
136
  * - GET /api/scraps/:id — history[0].status/statusDetail, the SAME
128
137
  * terminal-status signal `lastStatus()` above already trusts (status:null
129
138
  * === in flight, #88 item 1) to know when the run is done.
139
+ *
140
+ * #93 item 1 — dedup-race fix. "Is this the run we're watching?" used to be
141
+ * a single check: `history[0]._id !== beforeHistoryId`. That's wrong when
142
+ * `before.alreadyInFlight` is true (a `trigger` call deduped onto a worker
143
+ * job that was ALREADY pending/running at capture time): the top row IS the
144
+ * run we're watching, but its `_id` never changes, so the old guard never
145
+ * released and the poll ran the full timeout to a false "Timed out". The run
146
+ * we're watching is now EITHER a brand-new id (fresh trigger, the common
147
+ * case) OR the same id that was already in-flight (status:null) at capture
148
+ * (the dedup case) — a same-id row that was already TERMINAL at capture is
149
+ * neither, and must not be latched onto as "done" (it's just the previous
150
+ * run, still sitting there until a genuinely new run supersedes it).
130
151
  */
131
- export async function pollRunProgress(id, beforeHistoryId, opts = {}) {
152
+ export async function pollRunProgress(id, before, opts = {}) {
132
153
  const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
133
154
  const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
155
+ const beforeId = before?.id;
156
+ const beforeAlreadyInFlight = before?.alreadyInFlight ?? false;
134
157
  console.log(chalk.dim('Live activity streaming has no signal for this run (async/cross-pod) — polling for progress instead…\n'));
135
158
  const deadline = Date.now() + timeoutMs;
136
159
  const seen = new Set();
@@ -147,8 +170,12 @@ export async function pollRunProgress(id, beforeHistoryId, opts = {}) {
147
170
  continue; // transient — keep polling rather than aborting the wait
148
171
  }
149
172
  const last = scrap.history?.[0];
150
- if (!last?._id || last._id === beforeHistoryId)
151
- continue; // new run not recorded yet
173
+ if (!last?._id)
174
+ continue; // no history row recorded yet
175
+ const isNewRun = last._id !== beforeId;
176
+ const isDedupOntoInFlight = last._id === beforeId && beforeAlreadyInFlight;
177
+ if (!isNewRun && !isDedupOntoInFlight)
178
+ continue; // still the stale previous run
152
179
  try {
153
180
  const activities = await api.get(`/api/scraps/${id}/activities?history=${last._id}&limit=20`);
154
181
  // Server returns newest-first — print unseen ones oldest-first.
@@ -534,9 +561,10 @@ scraps
534
561
  .option('-w, --watch', 'Show progress after launching (polls — see `trawl scraps trigger --watch`, #91)')
535
562
  .action(async (id, opts) => {
536
563
  validateObjectId(id);
537
- // #91 P1 — captured BEFORE launching so pollRunProgress can tell "the run
538
- // that's about to finish" apart from whatever the last run happened to be.
539
- const beforeHistoryId = opts.watch ? await captureBeforeHistoryId(id) : undefined;
564
+ // #91 P1 / #93 item 1 — captured BEFORE launching so pollRunProgress can
565
+ // tell "the run that's about to finish" apart from whatever the last run
566
+ // happened to be (including a dedup onto an already-in-flight run).
567
+ const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
540
568
  // #91 P0 — GET /api/scraps/load/:id runs the scrap synchronously
541
569
  // server-side (30-250s); the 30s default was aborting it mid-flight.
542
570
  await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
@@ -544,7 +572,7 @@ scraps
544
572
  successText: 'Scrap launched',
545
573
  });
546
574
  if (opts.watch) {
547
- await pollRunProgress(id, beforeHistoryId);
575
+ await pollRunProgress(id, beforeRun);
548
576
  }
549
577
  });
550
578
  // #70 — render an items array either as a table summary or --json. Shared by
@@ -641,6 +669,27 @@ scraps
641
669
  console.log(chalk.dim('No data yet. Run the scrap first.'));
642
670
  return;
643
671
  }
672
+ // #93 item 2 — --fresh used to render items without ever checking for a
673
+ // regression, unlike the persisted `data` path below (#88 item 2). The
674
+ // load() response's OWN embedded `scrap.history[0]` can't be trusted for
675
+ // this (see the ScrapLoadResult comment above): it may be the previous
676
+ // run's row, and even the right row never carries the regression flip.
677
+ // `--fresh` runs synchronously to completion server-side though — by the
678
+ // time this call returns, node has already awaited the regression patch
679
+ // — so a fresh GET /api/scraps/:id (the SAME read the persisted path
680
+ // below already trusts) reliably observes the finalized DB state.
681
+ // Best-effort: never fail --fresh's real output over this side check,
682
+ // and never gate on it — the items returned ARE this run's real,
683
+ // synchronously-computed data regardless of what this check finds.
684
+ try {
685
+ const fresh = await api.get(`/api/scraps/${id}`);
686
+ if (fresh.history?.[0]?.statusDetail === 'regression') {
687
+ console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} — see: trawl scraps doctor ${id}`));
688
+ }
689
+ }
690
+ catch {
691
+ // best-effort — the fresh run's items are still valid without this check
692
+ }
644
693
  renderScrapItems(items, opts.json);
645
694
  return;
646
695
  }
@@ -862,9 +911,13 @@ scraps
862
911
  .option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
863
912
  .action(async (id, opts) => {
864
913
  validateObjectId(id);
865
- // #91 P1 — captured BEFORE triggering so pollRunProgress can tell "the
866
- // run we just triggered" apart from whatever the last run happened to be.
867
- const beforeHistoryId = opts.watch ? await captureBeforeHistoryId(id) : undefined;
914
+ // #91 P1 / #93 item 1 — captured BEFORE triggering so pollRunProgress can
915
+ // tell "the run we just triggered" apart from whatever the last run
916
+ // happened to be. This is the dedup-prone path: `trigger`'s method:'worker'
917
+ // collapses onto an already-pending/running worker job for the same scrap
918
+ // (ScrapJobsService, LIVE_STATUSES) instead of creating a new history row —
919
+ // captureBeforeRunState records that so pollRunProgress can still track it.
920
+ const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
868
921
  // #50 — default async: the backend (#1313) kicks off the run and returns a
869
922
  // 'queued' envelope immediately instead of holding the connection for the
870
923
  // whole run. --wait restores the old synchronous round-trip.
@@ -878,7 +931,7 @@ scraps
878
931
  successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
879
932
  });
880
933
  if (opts.watch)
881
- await pollRunProgress(id, beforeHistoryId);
934
+ await pollRunProgress(id, beforeRun);
882
935
  });
883
936
  // account subcommand group
884
937
  const account = scraps
@@ -34,13 +34,31 @@ skills
34
34
  // #91 — same EISDIR class as autoUpdateInstalledSkills/installSkill: an
35
35
  // unreadable `.version` marker on one skill must not crash the whole
36
36
  // listing before the other skills are shown.
37
+ //
38
+ // #93 item 3 — the two scope reads must be guarded INDEPENDENTLY. A
39
+ // single try/catch wrapped around `getInstalledVersion(name, 'user') ??
40
+ // getInstalledVersion(name, 'local')` still throws the whole expression
41
+ // the moment the 'user' read throws (e.g. a corrupt/EISDIR `.version`
42
+ // marker) — `??` never gets a chance to evaluate the 'local' fallback,
43
+ // so a healthy local install gets masked as "not installed" too. Every
44
+ // other marker read in this codebase (installSkill, removeOrphanedSkills,
45
+ // autoUpdateInstalledSkills — all in lib/skills.ts) already guards each
46
+ // scope on its own; this was the one chained exception.
37
47
  let installedVersion;
38
48
  try {
39
- installedVersion = getInstalledVersion(name, 'user') ?? getInstalledVersion(name, 'local');
49
+ installedVersion = getInstalledVersion(name, 'user');
40
50
  }
41
51
  catch {
42
52
  installedVersion = null;
43
53
  }
54
+ if (installedVersion === null) {
55
+ try {
56
+ installedVersion = getInstalledVersion(name, 'local');
57
+ }
58
+ catch {
59
+ installedVersion = null;
60
+ }
61
+ }
44
62
  const stale = installedVersion && installedVersion !== version;
45
63
  const tag = userInstalled
46
64
  ? localInstalled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.18.4",
3
+ "version": "1.18.5",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {