@boardwalk-labs/runner 0.1.21 → 0.2.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.
@@ -12,7 +12,7 @@
12
12
  import { AppError, ErrorCode } from "./support/index.js";
13
13
  import { LeafParked } from "@boardwalk-labs/engine/core";
14
14
  import { normalizeHumanInputResult } from "./wire/human_input.js";
15
- import { SeamSequencer, SuspendError, childRunIdSchema, determinismError, leafResumeSchema, seamFingerprint, SUSPEND_THRESHOLD_MS, } from "./suspension.js";
15
+ import { SuspensionCounter, SUSPEND_THRESHOLD_MS } from "./suspension.js";
16
16
  import { throwIfAborted } from "./run_abort.js";
17
17
  /** Parse a `humanInput({ timeout })` string (`"48h"`, `"30m"`, `"90s"`, `"7d"`) to milliseconds, or
18
18
  * null when absent/unparseable (the gate then waits indefinitely). */
@@ -68,7 +68,7 @@ export class WorkerWorkflowHost {
68
68
  now;
69
69
  maxSleepMs;
70
70
  heldPollIntervalMs;
71
- /** Synchronous durable-seam counter + silent-replay live flag (the durable-suspension design). */
71
+ /** Monotonic per-run counter keying suspensions + their HITL gate rows. */
72
72
  seq;
73
73
  /** Run context + on-demand public-API bearer the SDK `runtime` accessor reads off the host. */
74
74
  runtime;
@@ -78,25 +78,9 @@ export class WorkerWorkflowHost {
78
78
  this.now = deps.now ?? Date.now;
79
79
  this.maxSleepMs = deps.maxSleepMs ?? MAX_SLEEP_MS;
80
80
  this.heldPollIntervalMs = deps.heldPollIntervalMs ?? 3_000;
81
- this.seq = new SeamSequencer(deps.replayFrontier ?? 0);
81
+ this.seq = new SuspensionCounter();
82
82
  this.runtime = deps.runtime;
83
83
  }
84
- /** True while re-running already-journaled seams on a resume (the worker suppresses program-log +
85
- * phase observability during this window — those lines were emitted in the prior segment). */
86
- isReplaying() {
87
- return this.seq.isReplaying();
88
- }
89
- /** Raise a durable suspension. With `onSuspend` wired (the real worker) the host signals out-of-band
90
- * and returns a never-resolving promise, so the program's own try/catch can't swallow it; without
91
- * it (the local/test path) it rejects with {@link SuspendError}. */
92
- suspend(signal) {
93
- if (this.deps.onSuspend === undefined)
94
- return Promise.reject(new SuspendError(signal));
95
- this.deps.onSuspend(signal);
96
- return new Promise(() => {
97
- /* never settles: the worker races this against the body and tears the task down */
98
- });
99
- }
100
84
  /** Run `fn` only if the run isn't aborted; otherwise REJECT (never throw synchronously) with the
101
85
  * signal's RunAbortedError — every Promise-returning hook funnels through this so callers always
102
86
  * get a rejected promise on abort, not a sync throw. On the snapshot substrate the body also runs
@@ -185,6 +169,31 @@ export class WorkerWorkflowHost {
185
169
  });
186
170
  }
187
171
  }
172
+ /**
173
+ * The no-freeze HOLD for a human-input gate: register it with the broker (it surfaces in the
174
+ * inbox/API at once), then poll until the answer arrives — the process stays alive and pays
175
+ * for the wait. Rejects promptly when the run's abort signal fires (cancel / credit stop),
176
+ * which is also how a server-side gate expiry that fails the run unwinds this loop.
177
+ */
178
+ async holdForAnswer(seq, gate) {
179
+ const held = this.deps.heldInput;
180
+ if (held === undefined || gate === undefined) {
181
+ throw new AppError(ErrorCode.VALIDATION_FAILED, "humanInput is not available in this runtime (no control-plane connection to register the gate)", { kind: "human_input_unavailable" });
182
+ }
183
+ await held.register(seq, gate);
184
+ for (;;) {
185
+ throwIfAborted(this.deps.signal);
186
+ try {
187
+ const answers = await held.poll(seq);
188
+ if (gate.key in answers)
189
+ return answers[gate.key];
190
+ }
191
+ catch {
192
+ /* transient — retry next tick */
193
+ }
194
+ await this.sleeper.hold(this.heldPollIntervalMs, this.deps.signal);
195
+ }
196
+ }
188
197
  /** Map a froze/aborted freeze outcome to the gate's answer (or throw on an unexpected abort). */
189
198
  resolveFreezeAnswer(outcome, key) {
190
199
  if (outcome.kind !== "wake") {
@@ -204,64 +213,40 @@ export class WorkerWorkflowHost {
204
213
  }
205
214
  setPhase(name, opts) {
206
215
  throwIfAborted(this.deps.signal);
207
- // Suppressed during replay: the marker was already emitted in the prior segment.
208
- if (this.seq.isReplaying())
209
- return;
210
216
  this.deps.phases?.set(name, opts);
211
217
  }
212
218
  agent(prompt, opts) {
213
219
  return this.guarded(() => this.agentSeam(prompt, opts));
214
220
  }
215
- /** The `agent()` durable seam: a journal hit returns the memoized result (never re-runs the LLM); a
216
- * `suspended` hit resumes a parked leaf from its checkpoint + answers; a miss runs the leaf and
217
- * journals the result. A leaf that PARKS (the model called `human_input`) suspends the run. */
221
+ /** The `agent()` seam: run the leaf to completion. A leaf that PARKS (the model called the
222
+ * `human_input` tool) waits for the answer a freeze on the snapshot substrate, a held poll
223
+ * otherwise and re-enters from its in-memory checkpoint. Nothing is memoized: a crash-restart
224
+ * re-runs the leaf (restart-from-top semantics). */
218
225
  async agentSeam(prompt, opts) {
226
+ // One suspension key for the whole leaf: every gate this leaf raises registers under it, and
227
+ // a wake joins ALL of its answered rows, so answers accumulate server-side across parks.
219
228
  const seq = this.seq.next();
220
- const fingerprint = seamFingerprint([
221
- "agent",
222
- opts?.provider ?? null,
223
- opts?.model ?? null,
224
- prompt,
225
- opts?.schema ?? null,
226
- ]);
227
- let resume;
228
- if (this.deps.journal !== undefined) {
229
- const existing = await this.deps.journal.get(seq);
230
- if (existing !== null) {
231
- if (existing.fingerprint !== fingerprint)
232
- throw determinismError(seq, "agent", existing.kind);
233
- if (existing.state === "resolved")
234
- return existing.result;
235
- // A `suspended` entry is a parked leaf (tool-level human_input): re-enter it from the stored
236
- // checkpoint + the answers the broker joined in.
237
- resume = leafResumeSchema.parse(existing.result);
238
- }
239
- }
240
229
  // Bind a computer-use session's tools (browser tier ⇒ its in-VM Playwright MCP) if one was passed.
241
- // Done AFTER the fingerprint so the injected MCP never perturbs determinism (the fingerprint keys
242
- // on provider/model/prompt/schema only — an mcp/session change must not invalidate a journal hit).
243
230
  const effectiveOpts = this.bindBrowserSession(opts);
231
+ // Held-path answers accumulate locally too: a turn with several human_input calls parks once
232
+ // per unanswered gate, and each re-entry must still see every earlier answer.
233
+ const heldAnswers = {};
234
+ let resume;
244
235
  for (;;) {
245
236
  try {
246
- const result = await this.deps.leaf.run(prompt, effectiveOpts, this.deps.signal, resume);
247
- await this.deps.journal?.put({
248
- seq,
249
- kind: "agent",
250
- fingerprint,
251
- label: prompt.slice(0, 120),
252
- result,
253
- });
254
- return result;
237
+ return await this.deps.leaf.run(prompt, effectiveOpts, this.deps.signal, resume);
255
238
  }
256
239
  catch (err) {
257
240
  if (!(err instanceof LeafParked))
258
241
  throw err;
259
242
  // The model paused for a person (the in-leaf `human_input` tool).
243
+ if (err.checkpoint === undefined) {
244
+ throw new AppError(ErrorCode.INTERNAL_ERROR, "Parked agent leaf has no checkpoint to resume from", { kind: "leaf_parked_no_checkpoint", seq });
245
+ }
260
246
  const signal = {
261
247
  reason: "human_input",
262
248
  seq,
263
- fingerprint,
264
- ...(err.checkpoint !== undefined ? { leafCheckpoint: err.checkpoint } : {}),
249
+ leafCheckpoint: err.checkpoint,
265
250
  humanInput: {
266
251
  key: err.request.toolCallId,
267
252
  prompt: err.request.prompt,
@@ -269,14 +254,15 @@ export class WorkerWorkflowHost {
269
254
  },
270
255
  };
271
256
  const freeze = this.deps.freeze;
272
- if (freeze === undefined)
273
- return this.suspend(signal);
274
- // Snapshot substrate: freeze in place; the wake carries the answers and the leaf
275
- // re-enters from its checkpoint — heap intact, no journal round-trip. A leaf may park
276
- // again on a later turn, so this loops.
277
- if (err.checkpoint === undefined) {
278
- throw new AppError(ErrorCode.INTERNAL_ERROR, "Parked agent leaf has no checkpoint to resume from", { kind: "leaf_parked_no_checkpoint", seq });
257
+ if (freeze === undefined) {
258
+ // Hold path: register the gate and poll until answered; the transcript stays in memory.
259
+ heldAnswers[err.request.toolCallId] = await this.holdForAnswer(seq, signal.humanInput);
260
+ resume = { checkpoint: err.checkpoint, answers: { ...heldAnswers } };
261
+ continue;
279
262
  }
263
+ // Snapshot substrate: freeze in place; the wake carries the answers and the leaf
264
+ // re-enters from its checkpoint — heap intact. A leaf may park again on a later turn,
265
+ // so this loops.
280
266
  const outcome = await this.freezeWait(freeze, signal);
281
267
  if (outcome.kind !== "wake") {
282
268
  throw new AppError(ErrorCode.INTERNAL_ERROR, "Suspend aborted surfaced to a human-input seam (the coordinator retries these)", { kind: "unexpected_abort", seq });
@@ -288,32 +274,19 @@ export class WorkerWorkflowHost {
288
274
  }
289
275
  }
290
276
  }
291
- /** Program-level `humanInput()`: suspend the run on a gate, resume with the validated answer. The
292
- * SDK marks this optional; the hosted host always implements it. */
277
+ /** Program-level `humanInput()`: wait on a gate a freeze on the snapshot substrate, a held
278
+ * poll otherwise — and return the validated answer. The SDK marks this optional; the hosted
279
+ * host always implements it. */
293
280
  humanInput(opts) {
294
281
  return this.guarded(() => this.humanInputSeam(opts));
295
282
  }
296
283
  async humanInputSeam(opts) {
297
284
  const seq = this.seq.next();
298
285
  const key = opts.key ?? `seam-${String(seq)}`;
299
- const fingerprint = seamFingerprint(["human_input", key, opts.prompt, opts.input]);
300
- if (this.deps.journal !== undefined) {
301
- const existing = await this.deps.journal.get(seq);
302
- if (existing !== null) {
303
- if (existing.fingerprint !== fingerprint) {
304
- throw determinismError(seq, "human_input", existing.kind);
305
- }
306
- // A resolved entry is the human's validated response; a still-pending entry is a spurious
307
- // wake without an answer, so fall through and re-suspend.
308
- if (existing.state === "resolved")
309
- return normalizeHumanInputResult(existing.result);
310
- }
311
- }
312
286
  const expiresAt = this.timeoutExpiry(opts.timeout);
313
287
  const signal = {
314
288
  reason: "human_input",
315
289
  seq,
316
- fingerprint,
317
290
  humanInput: {
318
291
  key,
319
292
  prompt: opts.prompt,
@@ -327,111 +300,51 @@ export class WorkerWorkflowHost {
327
300
  if (freeze !== undefined) {
328
301
  return await this.freezeHumanInput(freeze, signal, key);
329
302
  }
330
- return this.suspend(signal);
303
+ // Hold path: register the gate and poll until a person answers.
304
+ return normalizeHumanInputResult(await this.holdForAnswer(seq, signal.humanInput));
331
305
  }
332
306
  /** Absolute wake time for a `humanInput({ timeout })`, or null when there is none / it's unparseable. */
333
307
  timeoutExpiry(timeout) {
334
308
  const ms = parseTimeoutMs(timeout);
335
309
  return ms === null ? null : this.now() + ms;
336
310
  }
337
- /** `step.run(name, fn)`: run `fn` exactly once across restarts, memoizing its result in the journal
338
- * (the escape hatch for nondeterministic work on a suspend/resume path). */
339
- step(name, fn) {
340
- return this.guarded(() => this.stepSeam(name, fn));
341
- }
342
- async stepSeam(name, fn) {
343
- const seq = this.seq.next();
344
- const fingerprint = seamFingerprint(["step", name]);
345
- if (this.deps.journal !== undefined) {
346
- const existing = await this.deps.journal.get(seq);
347
- if (existing !== null) {
348
- if (existing.fingerprint !== fingerprint)
349
- throw determinismError(seq, "step", existing.kind);
350
- if (existing.state === "resolved")
351
- return existing.result;
352
- }
353
- }
354
- const result = await fn();
355
- await this.deps.journal?.put({ seq, kind: "step", fingerprint, label: name, result });
356
- return result;
357
- }
358
311
  callWorkflow(slug, input, opts) {
359
312
  return this.guarded(() => this.callWorkflowSeam(slug, input, opts));
360
313
  }
361
- /** The `workflows.call` durable seam (the durable-suspension design): start the child once + memoize its
362
- * output; a non-terminal child SUSPENDS the parent (`waiting_for_child`, the child's id journaled)
363
- * the parent releases its task and is woken when the child finalizes. On resume the seam polls
364
- * the journaled child and returns its output (or throws on a failed child). Without a journal (the
365
- * local/test path) it falls back to the in-process hold-and-poll. */
314
+ /** The `workflows.call` seam: start the child once (idempotently the child's run row is the
315
+ * durable memo, so a crash-restarted parent re-attaches instead of re-spawning). A non-terminal
316
+ * child suspends the parent `waiting_for_child` on the snapshot substrate (the wake carries the
317
+ * finalized child, heap intact); without one the parent HOLDS in-process and polls. */
366
318
  async callWorkflowSeam(slug, input, opts) {
367
- if (this.deps.journal === undefined) {
319
+ const freeze = this.deps.freeze;
320
+ if (freeze === undefined) {
321
+ // Hold path: the dispatcher's in-process hold-and-poll until the child is terminal.
368
322
  return this.deps.children.call(slug, input, opts, this.deps.signal);
369
323
  }
370
- const seq = this.seq.next();
371
- const fingerprint = seamFingerprint([
372
- "workflow_call",
373
- slug,
374
- input ?? null,
375
- opts?.idempotencyKey ?? null,
376
- ]);
377
- let knownChildId;
378
- const existing = await this.deps.journal.get(seq);
379
- if (existing !== null) {
380
- if (existing.fingerprint !== fingerprint) {
381
- throw determinismError(seq, "workflow_call", existing.kind);
382
- }
383
- if (existing.state === "resolved")
384
- return existing.result;
385
- // A pending entry holds the child run id we suspended waiting on (resume polls it).
386
- knownChildId = childRunIdSchema.parse(existing.result);
387
- }
388
- // First execution starts (idempotently) the child; a resume polls the journaled one.
389
- const child = knownChildId === undefined
390
- ? await this.deps.children.start(slug, input, opts, this.deps.signal)
391
- : await this.deps.children.poll(knownChildId);
392
- if (child === null) {
393
- throw new AppError(ErrorCode.INTERNAL_ERROR, `Called workflow's child run vanished`, {
394
- slug,
395
- childRunId: knownChildId,
396
- });
397
- }
398
- if (child.status === "completed") {
399
- await this.deps.journal.put({
400
- seq,
401
- kind: "workflow_call",
402
- fingerprint,
403
- label: slug,
404
- result: child.output,
405
- });
324
+ const child = await this.deps.children.start(slug, input, opts, this.deps.signal);
325
+ if (child.status === "completed")
406
326
  return child.output;
407
- }
408
327
  if (child.status === "failed" || child.status === "cancelled") {
409
328
  throw new AppError(ErrorCode.VALIDATION_FAILED, `Called workflow "${slug}" ${child.status} (run ${child.childRunId})`, { childRunId: child.childRunId, status: child.status });
410
329
  }
411
- // Still running → suspend; the child's finalize wakes us.
412
- const signal = {
330
+ // Still running → park in place; the wake carries the finalized child directly (the heap
331
+ // holds this whole frame across the freeze).
332
+ const seq = this.seq.next();
333
+ const outcome = await this.freezeWait(freeze, {
413
334
  reason: "workflow_call",
414
335
  seq,
415
- fingerprint,
416
336
  childRunId: child.childRunId,
417
- };
418
- const freeze = this.deps.freeze;
419
- if (freeze !== undefined) {
420
- // Snapshot substrate: park in place; the wake carries the finalized child directly (no
421
- // journal write for the wake-derived value — the heap holds it and nothing replays).
422
- const outcome = await this.freezeWait(freeze, signal);
423
- if (outcome.kind !== "wake") {
424
- throw new AppError(ErrorCode.INTERNAL_ERROR, "Suspend aborted surfaced to a child-wait seam (the coordinator retries these)", { kind: "unexpected_abort", seq });
425
- }
426
- const woken = outcome.wake.child;
427
- if (outcome.wake.kind !== "workflow_call" || woken === undefined) {
428
- throw new AppError(ErrorCode.INTERNAL_ERROR, `Wake value does not carry the awaited child run (got kind "${outcome.wake.kind}")`, { kind: "wake_mismatch", childRunId: child.childRunId });
429
- }
430
- if (woken.status === "completed")
431
- return woken.output;
432
- throw new AppError(ErrorCode.VALIDATION_FAILED, `Called workflow "${slug}" ${woken.status} (run ${woken.run_id})`, { childRunId: woken.run_id, status: woken.status });
337
+ });
338
+ if (outcome.kind !== "wake") {
339
+ throw new AppError(ErrorCode.INTERNAL_ERROR, "Suspend aborted surfaced to a child-wait seam (the coordinator retries these)", { kind: "unexpected_abort", seq });
340
+ }
341
+ const woken = outcome.wake.child;
342
+ if (outcome.wake.kind !== "workflow_call" || woken === undefined) {
343
+ throw new AppError(ErrorCode.INTERNAL_ERROR, `Wake value does not carry the awaited child run (got kind "${outcome.wake.kind}")`, { kind: "wake_mismatch", childRunId: child.childRunId });
433
344
  }
434
- return this.suspend(signal);
345
+ if (woken.status === "completed")
346
+ return woken.output;
347
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `Called workflow "${slug}" ${woken.status} (run ${woken.run_id})`, { childRunId: woken.run_id, status: woken.status });
435
348
  }
436
349
  /** Fire-and-forget trigger of another workflow; resolves to the new run's id (no hold/poll). */
437
350
  runWorkflow(slug, input, opts) {
@@ -465,7 +378,7 @@ export class WorkerWorkflowHost {
465
378
  }
466
379
  /** `computer.openBrowser()`: open a program-owned, in-VM browser session (the browser tier of
467
380
  * computer use). Not a durable seam — a session is a live resource, reaped at run end, never
468
- * journaled/replayed. Absent backend ⇒ a clear "not available" error. */
381
+ * persisted. Absent backend ⇒ a clear "not available" error. */
469
382
  openBrowserSession(opts) {
470
383
  return this.guarded(async () => {
471
384
  if (this.deps.browserSessions === undefined) {
@@ -492,42 +405,26 @@ export class WorkerWorkflowHost {
492
405
  sleep(arg) {
493
406
  return this.guarded(() => this.sleepSeam(arg));
494
407
  }
495
- /** A short sleep HOLDS the task in-process (cheaper than a release + replay round-trip); a long one
496
- * (≥ {@link SUSPEND_THRESHOLD_MS}) SUSPENDS releases the task, and a timer re-dispatches the run
497
- * when due. Journaled so a resumed run replays past an already-elapsed sleep instantly. */
408
+ /** A short sleep HOLDS the task in-process (cheaper than a snapshot round-trip); a long one
409
+ * (≥ {@link SUSPEND_THRESHOLD_MS}) SUSPENDS on the snapshot substrate the VM freezes and the
410
+ * wake resolves this very await, heap intact. Without a freeze substrate EVERY sleep holds,
411
+ * whatever its length (the no-substrate rule: snapshot or hold, never replay). */
498
412
  async sleepSeam(arg) {
499
413
  const ms = this.resolveSleepMs(arg);
500
414
  if (ms > this.maxSleepMs) {
501
415
  throw new AppError(ErrorCode.VALIDATION_FAILED, `Sleep exceeds the ${String(this.maxSleepMs)}ms (7-day) maximum`, { kind: "sleep_cap" });
502
416
  }
503
- const seq = this.seq.next();
504
- const fingerprint = seamFingerprint(["sleep"]);
505
- if (this.deps.journal !== undefined) {
506
- const existing = await this.deps.journal.get(seq);
507
- if (existing !== null) {
508
- if (existing.fingerprint !== fingerprint)
509
- throw determinismError(seq, "sleep", existing.kind);
510
- // A journaled sleep already elapsed in a prior segment — a resumed run only progresses past a
511
- // sleep once it is due, so on replay this returns immediately.
512
- return;
513
- }
514
- }
515
417
  // A non-positive duration (incl. an `until` already in the past) is a no-op hold, not an error.
516
418
  const holdMs = Math.max(0, ms);
517
419
  if (holdMs === 0)
518
420
  return;
519
421
  const freeze = this.deps.freeze;
520
422
  if (holdMs >= SUSPEND_THRESHOLD_MS && freeze !== undefined) {
521
- // Snapshot substrate: freeze in place; the wake (when the sleep is due) resolves this very
522
- // await, heap intact. An abort falls back to holding the REMAINDER in-process — the
523
- // two-ways rule: snapshot or hold, never replay.
423
+ // Snapshot substrate: freeze in place. An abort falls back to holding the REMAINDER
424
+ // in-process.
524
425
  const requestedAt = this.now();
525
- const outcome = await this.freezeWait(freeze, {
526
- reason: "sleep",
527
- seq,
528
- fingerprint,
529
- durationMs: holdMs,
530
- });
426
+ const seq = this.seq.next();
427
+ const outcome = await this.freezeWait(freeze, { reason: "sleep", seq, durationMs: holdMs });
531
428
  if (outcome.kind === "wake")
532
429
  return;
533
430
  const remaining = holdMs - (this.now() - requestedAt);
@@ -538,14 +435,7 @@ export class WorkerWorkflowHost {
538
435
  await this.sleeper.hold(remaining, this.deps.signal);
539
436
  return;
540
437
  }
541
- // Long wait (transitional substrate): SUSPEND (release the task). The broker records the
542
- // (resolved) sleep journal entry + the wake time transactionally, so on wake this seam replays
543
- // past it (the journal hit above). Only when a journal is wired — without it there is no
544
- // resume, so fall back to holding.
545
- if (holdMs >= SUSPEND_THRESHOLD_MS && this.deps.journal !== undefined) {
546
- return this.suspend({ reason: "sleep", seq, fingerprint, durationMs: holdMs });
547
- }
548
- // Short wait: HOLD the process (no journal — a crash-restart simply re-holds). Snapshot the
438
+ // Hold: the process sleeps in place (a crash-restart simply re-holds). Snapshot the
549
439
  // persistent workspace first so a crash during the hold can restore it. An abort mid-hold rejects.
550
440
  if (this.deps.onBeforeSleep !== undefined)
551
441
  await this.deps.onBeforeSleep();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.1.21",
3
+ "version": "0.2.0",
4
4
  "description": "Boardwalk self-hosted runner: execute cloud-scheduled runs on your own machines. Currently: the canonical runner contract types.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -53,8 +53,8 @@
53
53
  "prebuild": "prebuildify --napi --strip -t 24.0.0"
54
54
  },
55
55
  "dependencies": {
56
- "@boardwalk-labs/engine": "^0.1.35",
57
- "@boardwalk-labs/workflow": "^0.1.29",
56
+ "@boardwalk-labs/engine": "^0.2.0",
57
+ "@boardwalk-labs/workflow": "^0.2.0",
58
58
  "@modelcontextprotocol/sdk": "^1.29.0",
59
59
  "node-gyp-build": "^4.8.4",
60
60
  "tar": "^7.5.16",