@neta-art/cohub-cli 10.8.0 → 10.8.1

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.
@@ -27527,21 +27527,9 @@ function fitTextToWidth(text, value, width) {
27527
27527
  }
27528
27528
  //#endregion
27529
27529
  //#region ../sdk/src/board/render/text-resolution.ts
27530
- const MAX_BOARD_RESOLUTION = 3;
27531
- const MAX_BOARD_PIXELS = 8e6;
27532
- const MAX_TEXT_RESOLUTION = 6;
27533
- function getBoardResolution(size, devicePixelRatio = globalThis.devicePixelRatio) {
27534
- const density = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
27535
- const pixels = size ? size.width * size.height : 0;
27536
- const budget = pixels > 0 ? Math.sqrt(MAX_BOARD_PIXELS / pixels) : MAX_BOARD_RESOLUTION;
27537
- return Math.min(density, MAX_BOARD_RESOLUTION, budget);
27538
- }
27539
- /**
27540
- * Zoom buckets for text re-rasterisation. Pixi Text is a bitmap: at high zoom it
27541
- * looks soft unless we raise its resolution. We quantise zoom into a few
27542
- * buckets so small zoom changes don't thrash texture regeneration.
27543
- */
27544
- const TEXT_ZOOM_BUCKETS = [
27530
+ const MIN_TEXT_RESOLUTION = .125;
27531
+ const MAX_TEXT_EDGE = 4096;
27532
+ const ZOOM_BUCKETS = [
27545
27533
  .5,
27546
27534
  1,
27547
27535
  1.5,
@@ -27551,23 +27539,17 @@ const TEXT_ZOOM_BUCKETS = [
27551
27539
  ];
27552
27540
  function textZoomBucket(zoom) {
27553
27541
  const clamped = Math.max(.1, Math.min(4, zoom));
27554
- for (const bucket of TEXT_ZOOM_BUCKETS) if (clamped <= bucket) return bucket;
27555
- return TEXT_ZOOM_BUCKETS[TEXT_ZOOM_BUCKETS.length - 1] ?? 4;
27542
+ for (const bucket of ZOOM_BUCKETS) if (clamped <= bucket) return bucket;
27543
+ return ZOOM_BUCKETS[ZOOM_BUCKETS.length - 1] ?? 4;
27556
27544
  }
27557
27545
  function boardCardDetailed(zoom) {
27558
27546
  return textZoomBucket(zoom) >= 1;
27559
27547
  }
27560
- /** Effective text resolution for a given camera zoom. */
27561
- function textResolutionForZoom(zoom) {
27562
- const bucket = textZoomBucket(zoom);
27563
- return Math.min(getBoardResolution() * Math.max(1, bucket), MAX_TEXT_RESOLUTION);
27564
- }
27565
- /** Update a Pixi text texture only when zoom crosses a resolution bucket. */
27566
- function syncTextResolution(text, state, zoom) {
27567
- const resolution = textResolutionForZoom(zoom);
27568
- if (resolution === state.resolution && resolution === text.resolution) return;
27569
- text.resolution = resolution;
27570
- state.resolution = resolution;
27548
+ function syncTextResolution(text, resolution) {
27549
+ const { width, height } = text.bounds;
27550
+ const limit = MAX_TEXT_EDGE / Math.max(1, Math.ceil(width + text.style.padding * 2), Math.ceil(height + text.style.padding * 2));
27551
+ const next = Math.min(limit, Math.max(MIN_TEXT_RESOLUTION, resolution));
27552
+ if (text.resolution !== next) text.resolution = next;
27571
27553
  }
27572
27554
  /** Defer expensive Pixi word-wrap rasterisation during a live resize. */
27573
27555
  function syncTextWrapWidth(text, state, width, defer) {
@@ -27749,13 +27731,15 @@ function createCardTag() {
27749
27731
  icon,
27750
27732
  text: null,
27751
27733
  sig: "",
27752
- resolution: 0,
27753
27734
  width: 0
27754
27735
  };
27755
27736
  }
27737
+ function tagScale(zoom) {
27738
+ return Math.min(MAX_GROWTH, Math.max(1, 1 / Math.max(zoom, .01)));
27739
+ }
27756
27740
  function zoomCardTag(tag, zoom) {
27757
27741
  if (!tag.text || !tag.sig) return;
27758
- const scale = Math.min(MAX_GROWTH, Math.max(1, 1 / Math.max(zoom, .01)));
27742
+ const scale = tagScale(zoom);
27759
27743
  const span = 17 + tag.text.width;
27760
27744
  tag.root.scale.set(scale);
27761
27745
  tag.root.position.set(0, -18 * scale);
@@ -27769,7 +27753,6 @@ function syncCardTag(tag, input, context) {
27769
27753
  return;
27770
27754
  }
27771
27755
  if (!tag.text) {
27772
- tag.resolution = textResolutionForZoom(context.zoom);
27773
27756
  tag.text = new Text({
27774
27757
  text: "",
27775
27758
  style: {
@@ -27777,30 +27760,27 @@ function syncCardTag(tag, input, context) {
27777
27760
  fontSize: 11,
27778
27761
  fontWeight: "500"
27779
27762
  },
27780
- resolution: tag.resolution,
27781
27763
  roundPixels: true
27782
27764
  });
27783
27765
  tag.root.addChild(tag.text);
27784
27766
  }
27785
- syncTextResolution(tag.text, tag, context.zoom);
27786
27767
  const sig = [
27787
27768
  input.glyph,
27788
27769
  input.text,
27789
27770
  input.width,
27790
27771
  context.themeKey
27791
27772
  ].join("|");
27792
- if (sig === tag.sig) {
27793
- zoomCardTag(tag, context.zoom);
27794
- return;
27795
- }
27796
- tag.sig = sig;
27797
- const color = context.palette.muted;
27798
- const pixel = context.style.icons === "pixel";
27799
- drawCardGlyph(tag.icon.clear(), input.glyph, color, pixel);
27800
- tag.text.style.fill = color;
27801
- tag.text.style.fontFamily = context.style.font;
27802
- fitTextToWidth(tag.text, input.text, Math.max(1, input.width - ICON - GAP$2));
27803
- tag.text.position.set(17, (ICON - tag.text.height) / 2);
27773
+ if (sig !== tag.sig) {
27774
+ tag.sig = sig;
27775
+ const color = context.palette.muted;
27776
+ const pixel = context.style.icons === "pixel";
27777
+ drawCardGlyph(tag.icon.clear(), input.glyph, color, pixel);
27778
+ tag.text.style.fill = color;
27779
+ tag.text.style.fontFamily = context.style.font;
27780
+ fitTextToWidth(tag.text, input.text, Math.max(1, input.width - ICON - GAP$2));
27781
+ tag.text.position.set(17, (ICON - tag.text.height) / 2);
27782
+ }
27783
+ syncTextResolution(tag.text, context.textResolution * tagScale(context.zoom));
27804
27784
  zoomCardTag(tag, context.zoom);
27805
27785
  }
27806
27786
  //#endregion
@@ -28111,9 +28091,9 @@ function cardElevation(context, id) {
28111
28091
  }
28112
28092
  //#endregion
28113
28093
  //#region ../sdk/src/board/render/renderers/base-card-renderer.ts
28114
- function createLabel(text, style) {
28094
+ function createLabel(text, style, resolution) {
28115
28095
  return new Text({
28116
- resolution: getBoardResolution(),
28096
+ resolution,
28117
28097
  roundPixels: true,
28118
28098
  text,
28119
28099
  style: {
@@ -28270,12 +28250,15 @@ function audioKeyCenter(width, height) {
28270
28250
  function sync$13(container, item, context) {
28271
28251
  const parts = partsByContainer$13.get(container);
28272
28252
  if (!parts) return;
28253
+ syncContent$1(parts, item, context);
28254
+ syncTextResolution(parts.title, context.textResolution);
28255
+ }
28256
+ function syncContent$1(parts, item, context) {
28273
28257
  positionShell(parts.root, item);
28274
28258
  const { width, height } = item.frame;
28275
28259
  const title = item.props.snapshot?.title ?? getMediaResourceTitle(item.props.src);
28276
28260
  const durationMs = item.props.snapshot?.durationMs;
28277
28261
  const detailed = boardCardDetailed(context.zoom);
28278
- syncTextResolution(parts.title, parts, context.zoom);
28279
28262
  const elevation = cardElevation(context, item.id);
28280
28263
  const sig = [
28281
28264
  width,
@@ -28328,14 +28311,13 @@ const audioCardRenderer = {
28328
28311
  const root = new Container();
28329
28312
  const plate = new Graphics();
28330
28313
  const wave = new Graphics();
28331
- const resolution = textResolutionForZoom(context.zoom);
28332
28314
  const title = new Text({
28333
28315
  text: "",
28334
28316
  style: {
28335
28317
  fontSize: 13,
28336
28318
  fontWeight: "600"
28337
28319
  },
28338
- resolution,
28320
+ resolution: context.textResolution,
28339
28321
  roundPixels: true
28340
28322
  });
28341
28323
  root.addChild(plate, wave, title);
@@ -28344,8 +28326,7 @@ const audioCardRenderer = {
28344
28326
  plate,
28345
28327
  wave,
28346
28328
  title,
28347
- sig: "",
28348
- resolution
28329
+ sig: ""
28349
28330
  });
28350
28331
  if (item.type === "audio") sync$13(root, item, context);
28351
28332
  return root;
@@ -28506,7 +28487,6 @@ function sync$11(container, item, context) {
28506
28487
  parts.line.alpha = itemAlpha(context, style.stroke);
28507
28488
  const color = itemColor(context, style.stroke, "brand");
28508
28489
  const size = style.strokeWidth ?? 2.5;
28509
- syncTextResolution(parts.label, parts, context.zoom);
28510
28490
  const resolved = resolveSceneArrow(item, context.scene);
28511
28491
  const path = trimPolyline(resolved.path, style.trim);
28512
28492
  const lineSig = [
@@ -28573,6 +28553,7 @@ function sync$11(container, item, context) {
28573
28553
  border: context.palette.border
28574
28554
  });
28575
28555
  }
28556
+ syncTextResolution(parts.label, context.textResolution);
28576
28557
  const visible = props.label.length > 0 && (style.trim === void 0 || style.trim >= .5);
28577
28558
  parts.label.visible = visible;
28578
28559
  parts.backdrop.visible = visible;
@@ -28586,7 +28567,6 @@ const arrowCardRenderer = {
28586
28567
  create: (item, context) => {
28587
28568
  const root = new Container();
28588
28569
  const line = new Graphics();
28589
- const resolution = textResolutionForZoom(context.zoom);
28590
28570
  const label = new Text({
28591
28571
  text: "",
28592
28572
  style: {
@@ -28595,7 +28575,7 @@ const arrowCardRenderer = {
28595
28575
  fontSize: 14,
28596
28576
  fontWeight: "500"
28597
28577
  },
28598
- resolution,
28578
+ resolution: context.textResolution,
28599
28579
  roundPixels: true
28600
28580
  });
28601
28581
  label.anchor.set(.5);
@@ -28607,8 +28587,7 @@ const arrowCardRenderer = {
28607
28587
  backdrop,
28608
28588
  label,
28609
28589
  lineSig: "",
28610
- labelSig: "",
28611
- resolution
28590
+ labelSig: ""
28612
28591
  });
28613
28592
  if (item.type === "arrow") sync$11(root, item, context);
28614
28593
  return root;
@@ -28618,7 +28597,7 @@ const arrowCardRenderer = {
28618
28597
  },
28619
28598
  resolution: (container, _item, context) => {
28620
28599
  const parts = partsByContainer$11.get(container);
28621
- if (parts) syncTextResolution(parts.label, parts, context.zoom);
28600
+ if (parts) syncTextResolution(parts.label, context.textResolution);
28622
28601
  },
28623
28602
  renderFar: (graphics, item, context) => {
28624
28603
  if (item.type !== "arrow") return;
@@ -29003,6 +28982,14 @@ function syncClip(parts, rect, style) {
29003
28982
  function sync$8(container, item, context) {
29004
28983
  const parts = partsByContainer$8.get(container);
29005
28984
  if (!parts) return;
28985
+ syncContent(parts, item, context);
28986
+ for (const text of [
28987
+ parts.title,
28988
+ parts.excerpt,
28989
+ parts.typeMark
28990
+ ]) syncTextResolution(text, context.textResolution);
28991
+ }
28992
+ function syncContent(parts, item, context) {
29006
28993
  positionShell(parts.root, item);
29007
28994
  const { width, height } = item.frame;
29008
28995
  const detail = detailFor(context.zoom);
@@ -29020,9 +29007,6 @@ function sync$8(container, item, context) {
29020
29007
  const kind = filePreviewKind(item.props.snapshot);
29021
29008
  const category = fileCategory(item.props.src, item.props.snapshot?.mimeType);
29022
29009
  const accent = fileCategoryAccent(category, context.palette);
29023
- syncTextResolution(parts.title, parts.titleRes, context.zoom);
29024
- syncTextResolution(parts.excerpt, parts.excerptRes, context.zoom);
29025
- syncTextResolution(parts.typeMark, parts.typeMarkRes, context.zoom);
29026
29010
  const visualSig = [
29027
29011
  key ?? "",
29028
29012
  width,
@@ -29146,7 +29130,7 @@ const fileCardRenderer = {
29146
29130
  const cover = new Sprite(Texture.EMPTY);
29147
29131
  const coverMask = new Graphics();
29148
29132
  cover.mask = coverMask;
29149
- const resolution = 1;
29133
+ const resolution = context.textResolution;
29150
29134
  const title = new Text({
29151
29135
  text: "",
29152
29136
  style: {
@@ -29203,10 +29187,7 @@ const fileCardRenderer = {
29203
29187
  title,
29204
29188
  excerpt,
29205
29189
  visualSig: "",
29206
- textSig: "",
29207
- titleRes: { resolution },
29208
- excerptRes: { resolution },
29209
- typeMarkRes: { resolution }
29190
+ textSig: ""
29210
29191
  });
29211
29192
  if (item.type === "file") sync$8(root, item, context);
29212
29193
  return root;
@@ -29244,7 +29225,6 @@ function sync$7(container, item, context) {
29244
29225
  if (!parts) return;
29245
29226
  const { frame, style, props, locked } = item;
29246
29227
  positionShell(parts.root, item);
29247
- syncTextResolution(parts.label, parts, context.zoom);
29248
29228
  const fill = itemColor(context, style.fill ?? style.stroke, "neutral", "fill");
29249
29229
  const fillAlpha = (style.fillOpacity ?? (style.fill ? 1 : DEFAULT_WASH)) * itemAlpha(context, style.fill ?? style.stroke);
29250
29230
  const baseStroke = itemColor(context, style.stroke, "neutral");
@@ -29300,6 +29280,7 @@ function sync$7(container, item, context) {
29300
29280
  parts.label.style.fill = labelColor;
29301
29281
  parts.label.style.fontFamily = context.style.font;
29302
29282
  }
29283
+ syncTextResolution(parts.label, context.textResolution);
29303
29284
  parts.label.position.set(4, -18);
29304
29285
  }
29305
29286
  const frameCardRenderer = {
@@ -29313,15 +29294,14 @@ const frameCardRenderer = {
29313
29294
  fontFamily: BOARD_FONT_STACK,
29314
29295
  fontSize: 12,
29315
29296
  fontWeight: "600"
29316
- });
29297
+ }, context.textResolution);
29317
29298
  root.addChild(box, label);
29318
29299
  partsByContainer$7.set(root, {
29319
29300
  root,
29320
29301
  box,
29321
29302
  label,
29322
29303
  visualSig: "",
29323
- textSig: "",
29324
- resolution: label.resolution
29304
+ textSig: ""
29325
29305
  });
29326
29306
  if (item.type === "frame") sync$7(root, item, context);
29327
29307
  return root;
@@ -29331,7 +29311,7 @@ const frameCardRenderer = {
29331
29311
  },
29332
29312
  resolution: (container, _item, context) => {
29333
29313
  const parts = partsByContainer$7.get(container);
29334
- if (parts) syncTextResolution(parts.label, parts, context.zoom);
29314
+ if (parts) syncTextResolution(parts.label, context.textResolution);
29335
29315
  },
29336
29316
  renderFar: (graphics, item, context) => {
29337
29317
  if (item.type !== "frame" || item.props.decoration === "none") return;
@@ -29440,7 +29420,6 @@ function sync$6(container, item, context) {
29440
29420
  const fillOpacity = (style.fillOpacity ?? (style.fill ? 1 : 0)) * itemAlpha(context, style.fill ?? style.stroke);
29441
29421
  const strokeAlpha = itemAlpha(context, style.stroke ?? style.fill);
29442
29422
  const strokeWidth = style.strokeWidth ?? 1.75;
29443
- syncTextResolution(parts.label, parts, context.zoom);
29444
29423
  const visualSig = [
29445
29424
  width,
29446
29425
  height,
@@ -29499,6 +29478,7 @@ function sync$6(container, item, context) {
29499
29478
  parts.label.style.align = props.align;
29500
29479
  }
29501
29480
  syncTextWrapWidth(parts.label, parts, Math.max(1, width - 16), context.resizingIds.has(item.id));
29481
+ syncTextResolution(parts.label, context.textResolution);
29502
29482
  parts.label.position.set(width / 2, height / 2);
29503
29483
  }
29504
29484
  const shapeCardRenderer = {
@@ -29507,7 +29487,6 @@ const shapeCardRenderer = {
29507
29487
  create: (item, context) => {
29508
29488
  const root = new Container();
29509
29489
  const shape = new Graphics();
29510
- const resolution = textResolutionForZoom(context.zoom);
29511
29490
  const label = new Text({
29512
29491
  text: "",
29513
29492
  style: {
@@ -29519,7 +29498,7 @@ const shapeCardRenderer = {
29519
29498
  wordWrap: true,
29520
29499
  lineHeight: 28
29521
29500
  },
29522
- resolution,
29501
+ resolution: context.textResolution,
29523
29502
  roundPixels: true
29524
29503
  });
29525
29504
  label.anchor.set(.5);
@@ -29530,8 +29509,7 @@ const shapeCardRenderer = {
29530
29509
  label,
29531
29510
  visualSig: "",
29532
29511
  textSig: "",
29533
- wrapWidth: 0,
29534
- resolution
29512
+ wrapWidth: 0
29535
29513
  });
29536
29514
  if (item.type === "shape") sync$6(root, item, context);
29537
29515
  return root;
@@ -29541,7 +29519,7 @@ const shapeCardRenderer = {
29541
29519
  },
29542
29520
  resolution: (container, _item, context) => {
29543
29521
  const parts = partsByContainer$6.get(container);
29544
- if (parts) syncTextResolution(parts.label, parts, context.zoom);
29522
+ if (parts) syncTextResolution(parts.label, context.textResolution);
29545
29523
  },
29546
29524
  renderFar: (graphics, item, context) => {
29547
29525
  if (item.type !== "shape") return;
@@ -29568,7 +29546,6 @@ function sync$5(container, item, context) {
29568
29546
  parts.id = item.id;
29569
29547
  }
29570
29548
  positionShell(parts.root, item);
29571
- syncTextResolution(parts.label, parts.label, context.zoom);
29572
29549
  const { width, height } = item.frame;
29573
29550
  const texture = context.scripts?.frame(item, context.time) ?? null;
29574
29551
  const error = context.scripts?.error(item.id) ?? null;
@@ -29586,19 +29563,21 @@ function sync$5(container, item, context) {
29586
29563
  message,
29587
29564
  context.themeKey
29588
29565
  ].join("|");
29589
- if (sig === parts.sig) return;
29590
- parts.sig = sig;
29591
- parts.placeholder.clear();
29592
- if (!ready || error) parts.placeholder.rect(0, 0, width, height).stroke({
29593
- color: context.palette.border,
29594
- width: 1,
29595
- alpha: .8
29596
- });
29597
- parts.label.visible = !ready || Boolean(error);
29598
- parts.label.text = message;
29599
- parts.label.style.fill = error ? context.colors.rose.stroke : context.palette.muted;
29600
- parts.label.style.wordWrapWidth = Math.max(40, width - 16);
29601
- parts.label.position.set(width / 2, height / 2);
29566
+ if (sig !== parts.sig) {
29567
+ parts.sig = sig;
29568
+ parts.placeholder.clear();
29569
+ if (!ready || error) parts.placeholder.rect(0, 0, width, height).stroke({
29570
+ color: context.palette.border,
29571
+ width: 1,
29572
+ alpha: .8
29573
+ });
29574
+ parts.label.visible = !ready || Boolean(error);
29575
+ parts.label.text = message;
29576
+ parts.label.style.fill = error ? context.colors.rose.stroke : context.palette.muted;
29577
+ parts.label.style.wordWrapWidth = Math.max(40, width - 16);
29578
+ parts.label.position.set(width / 2, height / 2);
29579
+ }
29580
+ syncTextResolution(parts.label, context.textResolution);
29602
29581
  }
29603
29582
  const customCardRenderer = {
29604
29583
  id: "custom-card",
@@ -29617,7 +29596,8 @@ const customCardRenderer = {
29617
29596
  wordWrap: true,
29618
29597
  breakWords: true
29619
29598
  },
29620
- resolution: getBoardResolution()
29599
+ resolution: context.textResolution,
29600
+ roundPixels: true
29621
29601
  });
29622
29602
  label.anchor.set(.5);
29623
29603
  root.addChild(sprite, placeholder, label);
@@ -29637,7 +29617,7 @@ const customCardRenderer = {
29637
29617
  },
29638
29618
  resolution: (container, _item, context) => {
29639
29619
  const parts = partsByContainer$5.get(container);
29640
- if (parts) syncTextResolution(parts.label, parts.label, context.zoom);
29620
+ if (parts) syncTextResolution(parts.label, context.textResolution);
29641
29621
  },
29642
29622
  renderFar: (graphics, item, context) => {
29643
29623
  drawFarPlate(graphics, item.frame, {
@@ -30047,10 +30027,6 @@ function sync$3(container, item, context) {
30047
30027
  const detailText = !full || !surface ? "" : status === "failed" ? snapshot.errorExcerpt ?? "" : working ? snapshot.promptExcerpt ?? "" : "";
30048
30028
  const textArtifact = kind === "text" && artifact?.type === "text";
30049
30029
  const bodyText = textArtifact ? artifact.textExcerpt : counting ? `${label} · ${elapsed}` : label;
30050
- syncTextResolution(parts.body, parts, context.zoom);
30051
- syncTextResolution(parts.detail, parts, context.zoom);
30052
- syncTextResolution(parts.meta, parts, context.zoom);
30053
- syncTextResolution(parts.badge, parts, context.zoom);
30054
30030
  const textSig = [
30055
30031
  counting ? label : bodyText,
30056
30032
  detailText,
@@ -30081,6 +30057,12 @@ function sync$3(container, item, context) {
30081
30057
  parts.badge.style.fill = context.palette.text;
30082
30058
  if (parts.badge.text !== count) parts.badge.text = count;
30083
30059
  }
30060
+ for (const text of [
30061
+ parts.body,
30062
+ parts.detail,
30063
+ parts.meta,
30064
+ parts.badge
30065
+ ]) syncTextResolution(text, context.textResolution);
30084
30066
  const badgeWidth = Math.max(22, parts.badge.width + 12);
30085
30067
  const visualSig = [
30086
30068
  width,
@@ -30198,7 +30180,6 @@ const taskCardRenderer = {
30198
30180
  const previewMask = new Graphics();
30199
30181
  const shimmer = new Graphics();
30200
30182
  const previewArt = new Graphics();
30201
- const resolution = textResolutionForZoom(context.zoom);
30202
30183
  const text = (fontFamily, fontSize, fontWeight) => new Text({
30203
30184
  text: "",
30204
30185
  style: {
@@ -30208,7 +30189,7 @@ const taskCardRenderer = {
30208
30189
  wordWrap: true,
30209
30190
  breakWords: true
30210
30191
  },
30211
- resolution,
30192
+ resolution: context.textResolution,
30212
30193
  roundPixels: true
30213
30194
  });
30214
30195
  const body = text(BOARD_FONT_STACK, 11.5, "500");
@@ -30242,7 +30223,6 @@ const taskCardRenderer = {
30242
30223
  assetKey: null,
30243
30224
  visualSig: "",
30244
30225
  textSig: "",
30245
- resolution,
30246
30226
  motion: {
30247
30227
  itemId: "",
30248
30228
  width: 0,
@@ -30319,7 +30299,6 @@ function sync$2(container, item, context) {
30319
30299
  const anchorX = props.align === "center" ? .5 : props.align === "right" ? 1 : 0;
30320
30300
  parts.body.anchor.set(anchorX, 0);
30321
30301
  parts.body.position.set(item.frame.width * anchorX, 0);
30322
- syncTextResolution(parts.body, parts, context.zoom * Math.max(1, previewScale));
30323
30302
  parts.body.alpha = itemAlpha(context, item.style.fill);
30324
30303
  parts.caret.alpha = parts.body.alpha;
30325
30304
  const ink = itemColor(context, item.style.fill, "neutral");
@@ -30349,6 +30328,7 @@ function sync$2(container, item, context) {
30349
30328
  breakWords: true
30350
30329
  });
30351
30330
  }
30331
+ syncTextResolution(parts.body, context.textResolution * previewScale);
30352
30332
  syncCaret(parts, item, context);
30353
30333
  }
30354
30334
  const textCardRenderer = {
@@ -30356,11 +30336,10 @@ const textCardRenderer = {
30356
30336
  canRender: (item) => item.type === "text",
30357
30337
  create: (item, context) => {
30358
30338
  const root = new Container();
30359
- const resolution = textResolutionForZoom(context.zoom);
30360
30339
  const body = new Text({
30361
30340
  text: "",
30362
30341
  style: { fontFamily: BOARD_FONT_STACKS.sans },
30363
- resolution,
30342
+ resolution: context.textResolution,
30364
30343
  roundPixels: true
30365
30344
  });
30366
30345
  const caret = new Graphics();
@@ -30369,7 +30348,6 @@ const textCardRenderer = {
30369
30348
  root,
30370
30349
  body,
30371
30350
  caret,
30372
- resolution,
30373
30351
  contentSig: ""
30374
30352
  });
30375
30353
  if (item.type === "text") sync$2(root, item, context);
@@ -30380,7 +30358,7 @@ const textCardRenderer = {
30380
30358
  },
30381
30359
  resolution: (container, _item, context) => {
30382
30360
  const parts = partsByContainer$2.get(container);
30383
- if (parts) syncTextResolution(parts.body, parts, context.zoom * Math.max(1, parts.body.scale.x));
30361
+ if (parts) syncTextResolution(parts.body, context.textResolution * parts.body.scale.x);
30384
30362
  },
30385
30363
  renderFar: (graphics, item, context) => {
30386
30364
  if (item.type !== "text") return;
@@ -30403,7 +30381,6 @@ function sync$1(container, item, context) {
30403
30381
  positionShell(parts.root, item);
30404
30382
  const { width, height } = item.frame;
30405
30383
  const realType = item.type;
30406
- syncTextResolution(parts.label, parts, context.zoom);
30407
30384
  const elevation = cardElevation(context, item.id);
30408
30385
  const visualSig = [
30409
30386
  width,
@@ -30438,6 +30415,7 @@ function sync$1(container, item, context) {
30438
30415
  parts.label.text = realType;
30439
30416
  parts.label.style.fill = context.palette.muted;
30440
30417
  }
30418
+ syncTextResolution(parts.label, context.textResolution);
30441
30419
  parts.label.position.set(width / 2, height / 2);
30442
30420
  }
30443
30421
  const unknownCardRenderer = {
@@ -30447,7 +30425,6 @@ const unknownCardRenderer = {
30447
30425
  const root = new Container();
30448
30426
  const box = new Graphics();
30449
30427
  box.context.batchMode = "batch";
30450
- const resolution = textResolutionForZoom(context.zoom);
30451
30428
  const label = new Text({
30452
30429
  text: "",
30453
30430
  style: {
@@ -30455,7 +30432,7 @@ const unknownCardRenderer = {
30455
30432
  fontSize: 11,
30456
30433
  fontWeight: "600"
30457
30434
  },
30458
- resolution,
30435
+ resolution: context.textResolution,
30459
30436
  roundPixels: true
30460
30437
  });
30461
30438
  label.anchor.set(.5);
@@ -30465,8 +30442,7 @@ const unknownCardRenderer = {
30465
30442
  box,
30466
30443
  label,
30467
30444
  visualSig: "",
30468
- textSig: "",
30469
- resolution
30445
+ textSig: ""
30470
30446
  });
30471
30447
  sync$1(root, item, context);
30472
30448
  return root;
@@ -30476,7 +30452,7 @@ const unknownCardRenderer = {
30476
30452
  },
30477
30453
  resolution: (container, _item, context) => {
30478
30454
  const parts = partsByContainer$1.get(container);
30479
- if (parts) syncTextResolution(parts.label, parts, context.zoom);
30455
+ if (parts) syncTextResolution(parts.label, context.textResolution);
30480
30456
  },
30481
30457
  renderFar: (graphics, item, context) => {
30482
30458
  drawFarPlate(graphics, item.frame, {
@@ -31642,6 +31618,7 @@ function buildContext(input) {
31642
31618
  themeKey: "export",
31643
31619
  rendererType: "canvas",
31644
31620
  zoom: input.scale,
31621
+ textResolution: input.scale,
31645
31622
  reducedMotion: true,
31646
31623
  labels: DEFAULT_BOARD_RENDER_LABELS,
31647
31624
  taskRun: () => null,
@@ -4,6 +4,7 @@ import { RUNTIME_MAX_FRAME_BYTES, RUNTIME_PROTOCOL_VERSION, runtimeCommandSchema
4
4
  import { describeError } from "../network-error.js";
5
5
  import { executeTurn } from "./native/execution.js";
6
6
  import { ContextRequiredError } from "./native/results.js";
7
+ import { SizeLimitError } from "./native/transcript.js";
7
8
  import { ProcessCleanupUncertainError } from "./process-group.js";
8
9
  import { serializeDiagnosticError, } from "./diagnostics.js";
9
10
  export async function serveRuntime(options) {
@@ -11,6 +12,11 @@ export async function serveRuntime(options) {
11
12
  let backoff = 500;
12
13
  let attempt = 0;
13
14
  let conflictSince = null;
15
+ // Executions outlive a single transport so a dropped socket reconnects immediately
16
+ // instead of waiting for in-flight turns to finish.
17
+ const executions = new Map();
18
+ const seen = new Set();
19
+ const announce = { current: null };
14
20
  const uploads = new AbortController();
15
21
  const uploadSignal = AbortSignal.any([options.signal, uploads.signal]);
16
22
  const log = (level, event, data, context) => options.diagnostics?.log(level, event, data, context);
@@ -33,12 +39,16 @@ export async function serveRuntime(options) {
33
39
  while (!options.signal.aborted) {
34
40
  attempt += 1;
35
41
  let readyAt = 0;
42
+ const connectStartedAt = Date.now();
36
43
  let outcome;
37
44
  try {
38
45
  outcome = await connect({
39
46
  ...options,
40
47
  runtimeId,
41
48
  attempt,
49
+ executions,
50
+ seen,
51
+ announce,
42
52
  onReady: () => {
43
53
  readyAt = Date.now();
44
54
  conflictSince = null;
@@ -72,18 +82,40 @@ export async function serveRuntime(options) {
72
82
  attempt: attempt || 1,
73
83
  delayMs: backoff,
74
84
  outcome: outcome.kind,
85
+ connectedMs: Date.now() - connectStartedAt,
86
+ readyMs: readyAt ? Date.now() - readyAt : null,
87
+ activeExecutions: executions.size,
75
88
  });
76
89
  await delay(backoff / 2 + Math.random() * backoff / 2, undefined, { signal: options.signal }).catch(() => undefined);
77
90
  backoff = Math.min(30_000, backoff * 2);
78
91
  }
79
92
  }
80
93
  finally {
94
+ for (const execution of executions.values())
95
+ execution.controller.abort();
96
+ await Promise.allSettled([...executions.values()].map((entry) => entry.promise));
81
97
  clearInterval(timer);
82
98
  uploads.abort();
83
99
  await flush();
84
100
  log("info", "runtime.stopped", { runtimeId });
85
101
  }
86
102
  }
103
+ function frameIdentity(frame) {
104
+ const value = frame;
105
+ const event = value?.event;
106
+ const pick = (input) => typeof input === "string" ? input : undefined;
107
+ return {
108
+ frameType: pick(value?.type),
109
+ requestId: pick(value?.requestId),
110
+ eventType: pick(event?.type),
111
+ sessionId: pick(event?.sessionId) ?? pick(event?.input?.sessionId),
112
+ turnId: pick(event?.turnId) ?? pick(event?.input?.turnId),
113
+ harness: pick(event?.input?.harness),
114
+ nativeSessionId: pick(event?.input?.nativeSessionId),
115
+ turnCount: Array.isArray(event?.input?.turns) ? event.input.turns.length : undefined,
116
+ turnIds: Array.isArray(event?.input?.turns) ? event.input.turns.slice(0, 10).map((turn) => pick(turn?.turnId)).filter(Boolean) : undefined,
117
+ };
118
+ }
87
119
  function executionContext(connectionId, input) {
88
120
  return {
89
121
  connectionId,
@@ -112,11 +144,13 @@ async function connect(options) {
112
144
  const runtimeUrl = new URL(options.url);
113
145
  runtimeUrl.searchParams.set("runtimeId", options.runtimeId);
114
146
  const socket = new WebSocket(runtimeUrl.toString());
115
- const active = new Map();
116
- const seen = new Set();
147
+ const active = options.executions;
148
+ const seen = options.seen;
117
149
  const disconnected = new AbortController();
118
150
  const contexts = new Map();
119
151
  let lastHeartbeat = Date.now();
152
+ let openedAt = 0;
153
+ let readyAt = 0;
120
154
  let connectionId = null;
121
155
  let fatal = false;
122
156
  let fatalCode = 0;
@@ -137,8 +171,10 @@ async function connect(options) {
137
171
  const data = JSON.stringify(frame);
138
172
  const bytes = Buffer.byteLength(data);
139
173
  if (bytes > RUNTIME_MAX_FRAME_BYTES) {
140
- log("error", "runtime.frame_too_large", { bytes, maxBytes: RUNTIME_MAX_FRAME_BYTES }, { connectionId });
141
- throw new Error("Runtime frame exceeds transfer limit");
174
+ const identity = frameIdentity(frame);
175
+ const execution = identity.requestId ? active.get(identity.requestId) : undefined;
176
+ log("error", "runtime.frame_too_large", { bytes, maxBytes: RUNTIME_MAX_FRAME_BYTES, ...identity }, execution ? executionContext(connectionId, execution) : { connectionId });
177
+ throw new SizeLimitError("Runtime frame exceeds transfer limit");
142
178
  }
143
179
  socket.send(data);
144
180
  };
@@ -173,8 +209,13 @@ async function connect(options) {
173
209
  log("error", "runtime.heartbeat_timeout", {
174
210
  ageMs: heartbeatAgeMs,
175
211
  activeExecutions: active.size,
212
+ readyState: socket.readyState,
213
+ bufferedBytes: socket.bufferedAmount,
176
214
  }, { connectionId });
177
215
  closeTransport();
216
+ // A half-open TCP path may never deliver the close event; stop waiting so reconnect can start.
217
+ if (heartbeatAgeMs > 45_000)
218
+ handleClose({ code: 1006, reason: "Runtime heartbeat timed out", wasClean: false }, true);
178
219
  }
179
220
  else if (connectionId) {
180
221
  try {
@@ -206,52 +247,108 @@ async function connect(options) {
206
247
  });
207
248
  }
208
249
  }, 10_000);
209
- const closed = new Promise((resolve) => {
210
- socket.addEventListener("close", (event) => {
211
- unauthorized = event.code === 4401;
212
- fatal = [4400, 4403].includes(event.code);
213
- if (fatal) {
214
- fatalCode = event.code;
215
- fatalReason = event.reason;
250
+ const announceHere = (execution) => {
251
+ try {
252
+ send({ type: "runtime.recovery", executions: [execution] });
253
+ log("info", "runtime.detached_result_announced", undefined, executionContext(connectionId, execution));
254
+ }
255
+ catch {
256
+ // The next ready connection reports every pending receipt.
257
+ }
258
+ };
259
+ /** Replays a saved result, or answers explicitly that none is available; never leaves the server waiting. */
260
+ const replaySaved = async (requestId, input, context, executionRequestId) => {
261
+ let saved = null;
262
+ try {
263
+ saved = await options.executor.results.recover(input, executionRequestId);
264
+ }
265
+ catch (error) {
266
+ log("error", "runtime.recovery_failed", { error: serializeDiagnosticError(error) }, context);
267
+ }
268
+ if (saved)
269
+ for (const event of saved.events)
270
+ send({ type: "runtime.event", requestId, event });
271
+ else
272
+ send({ type: "runtime.event", requestId, event: { type: "turn.error", uncertain: true, message: "Result unavailable; files retained" } });
273
+ };
274
+ let closeHandled = false;
275
+ let resolveClosed = () => { };
276
+ const closed = new Promise((resolve) => { resolveClosed = resolve; });
277
+ const handleClose = (event, forced = false) => {
278
+ if (closeHandled)
279
+ return;
280
+ closeHandled = true;
281
+ unauthorized = event.code === 4401;
282
+ fatal = [4400, 4403].includes(event.code);
283
+ if (fatal) {
284
+ fatalCode = event.code;
285
+ fatalReason = event.reason;
286
+ }
287
+ conflict = event.code === 4409;
288
+ disconnected.abort();
289
+ const now = Date.now();
290
+ log(options.signal.aborted ? "debug" : fatal || conflict ? "error" : "warn", fatal ? "runtime.websocket.rejected" : "runtime.websocket.closed", {
291
+ code: event.code,
292
+ reason: event.reason,
293
+ wasClean: event.wasClean,
294
+ forced,
295
+ durationMs: now - connectedAt,
296
+ openMs: openedAt ? now - openedAt : null,
297
+ readyMs: readyAt ? now - readyAt : null,
298
+ lastInboundAgeMs: now - lastHeartbeat,
299
+ bufferedBytes: socket.bufferedAmount,
300
+ activeExecutions: active.size,
301
+ fatal,
302
+ conflict,
303
+ }, { connectionId });
304
+ nativeChannelReady = false;
305
+ for (const pending of nativePending.values()) {
306
+ clearTimeout(pending.timer);
307
+ pending.reject(new Error("Runtime native channel disconnected"));
308
+ }
309
+ nativePending.clear();
310
+ options.onDisconnected?.();
311
+ const executionMustStop = options.signal.aborted || fatal || conflict;
312
+ if (options.announce.current === announceHere)
313
+ options.announce.current = null;
314
+ for (const [requestId, execution] of active) {
315
+ if (executionMustStop) {
316
+ log("error", "runtime.execution_transport_invalidated", { code: event.code, reason: event.reason }, executionContext(connectionId, execution));
317
+ execution.controller.abort();
216
318
  }
217
- conflict = event.code === 4409;
218
- disconnected.abort();
219
- log(options.signal.aborted ? "debug" : fatal || conflict ? "error" : "warn", fatal ? "runtime.websocket.rejected" : "runtime.websocket.closed", {
220
- code: event.code,
221
- reason: event.reason,
222
- durationMs: Date.now() - connectedAt,
223
- activeExecutions: active.size,
224
- fatal,
225
- conflict,
226
- }, { connectionId });
227
- nativeChannelReady = false;
228
- for (const pending of nativePending.values()) {
229
- clearTimeout(pending.timer);
230
- pending.reject(new Error("Runtime native channel disconnected"));
319
+ else if (execution.recovery) {
320
+ // The server re-asks on the next connection; a stale waiter must not hold a slot or its request id.
321
+ execution.controller.abort();
231
322
  }
232
- nativePending.clear();
233
- options.onDisconnected?.();
234
- const executionMustStop = options.signal.aborted || fatal || conflict;
235
- for (const execution of active.values()) {
236
- log(executionMustStop ? "error" : "warn", executionMustStop ? "runtime.execution_transport_invalidated" : "runtime.execution_transport_detached", {
237
- code: event.code,
238
- reason: event.reason,
239
- }, executionContext(connectionId, execution));
240
- if (executionMustStop)
241
- execution.controller.abort();
323
+ else if (!execution.detached) {
324
+ execution.detached = true;
325
+ log("warn", "runtime.execution_transport_detached", { code: event.code, reason: event.reason }, executionContext(connectionId, execution));
326
+ // The server may have stopped asking by the time this finishes; a saved result is announced again.
327
+ void execution.promise.finally(() => {
328
+ if (!execution.result || active.get(requestId) !== execution)
329
+ return;
330
+ options.announce.current?.({ sessionId: execution.sessionId, turnId: execution.turnId, harness: execution.harness });
331
+ });
242
332
  }
243
- resolve();
244
- }, { once: true });
245
- });
333
+ }
334
+ resolveClosed();
335
+ };
336
+ socket.addEventListener("close", (event) => handleClose(event), { once: true });
246
337
  socket.addEventListener("error", (event) => {
247
- const detail = event.error;
338
+ const detail = event;
248
339
  log("warn", "runtime.websocket.error", {
249
340
  type: event.type,
250
- error: serializeDiagnosticError(detail ?? new Error("WebSocket error")),
341
+ message: typeof detail.message === "string" && detail.message ? detail.message : undefined,
342
+ readyState: socket.readyState,
343
+ opened: openedAt > 0,
344
+ ready: readyAt > 0,
345
+ lastInboundAgeMs: Date.now() - lastHeartbeat,
346
+ error: serializeDiagnosticError(detail.error ?? new Error("WebSocket error")),
251
347
  }, { connectionId });
252
348
  socket.close();
253
349
  });
254
350
  socket.addEventListener("open", () => {
351
+ openedAt = Date.now();
255
352
  try {
256
353
  log("info", "runtime.websocket.open", {
257
354
  attempt: options.attempt,
@@ -289,14 +386,17 @@ async function connect(options) {
289
386
  if (connectionId)
290
387
  throw new Error("Runtime connection identity changed");
291
388
  connectionId = frame.connectionId;
389
+ readyAt = Date.now();
292
390
  clearTimeout(readyTimer);
293
391
  log("info", "runtime.ready", {
294
392
  connectionId,
295
- durationMs: Date.now() - connectedAt,
393
+ durationMs: readyAt - connectedAt,
394
+ detachedExecutions: [...active.values()].filter((entry) => entry.detached).length,
296
395
  }, { connectionId });
297
396
  nativeChannelReady = true;
298
397
  options.onNativeChannel?.(sendNative);
299
398
  options.onReady();
399
+ options.announce.current = announceHere;
300
400
  let recoveryBatch = 0;
301
401
  for await (const executions of options.executor.results.pendingBatches()) {
302
402
  recoveryBatch += 1;
@@ -391,10 +491,7 @@ async function connect(options) {
391
491
  if (previous.sessionId !== identity.sessionId || previous.turnId !== identity.turnId || previous.harness !== identity.harness)
392
492
  throw new Error("Runtime recovery identity changed");
393
493
  await previous.promise;
394
- const saved = await options.executor.results.recover(identity);
395
- if (saved)
396
- for (const event of saved.events)
397
- send({ type: "runtime.event", requestId: frame.requestId, event });
494
+ await replaySaved(frame.requestId, identity, context);
398
495
  return;
399
496
  }
400
497
  const running = [...active].find(([, entry]) => entry.turnId === identity.turnId && entry.sessionId === identity.sessionId && entry.harness === identity.harness);
@@ -404,14 +501,26 @@ async function connect(options) {
404
501
  traceContext: frame.traceContext,
405
502
  controller: new AbortController(),
406
503
  promise: Promise.resolve(),
504
+ recovery: true,
407
505
  };
408
506
  active.set(frame.requestId, recovery);
409
507
  recovery.promise = (async () => {
410
508
  try {
411
509
  if (running) {
412
- running[1].controller.abort();
413
- await running[1].promise;
414
- active.delete(running[0]);
510
+ // A detached execution was not superseded by this host; wait for its result instead of discarding it.
511
+ if (running[1].detached) {
512
+ log("info", "runtime.recovery_waiting_detached", { runningRequestId: running[0] }, context);
513
+ // A recovery the server gave up on must not hold an execution slot until the Turn ends.
514
+ const stopped = recovery.controller.signal;
515
+ await Promise.race([running[1].promise, new Promise((resolve) => stopped.addEventListener("abort", () => resolve(), { once: true }))]);
516
+ stopped.throwIfAborted();
517
+ }
518
+ else {
519
+ running[1].controller.abort();
520
+ await running[1].promise;
521
+ }
522
+ if (active.get(running[0]) === running[1])
523
+ active.delete(running[0]);
415
524
  }
416
525
  const saved = await options.executor.results.recover(identity);
417
526
  recovery.controller.signal.throwIfAborted();
@@ -433,7 +542,8 @@ async function connect(options) {
433
542
  // The next connection can read the same result.
434
543
  }
435
544
  }
436
- active.delete(frame.requestId);
545
+ if (active.get(frame.requestId) === recovery)
546
+ active.delete(frame.requestId);
437
547
  }
438
548
  })();
439
549
  return;
@@ -449,11 +559,9 @@ async function connect(options) {
449
559
  if (previous) {
450
560
  if (previous.sessionId !== frame.input.sessionId || previous.turnId !== frame.input.turnId || previous.harness !== frame.input.harness)
451
561
  throw new Error("Runtime execution identity changed");
562
+ log("info", "runtime.execution_resumed", { detached: previous.detached === true, settled: Boolean(previous.result) }, executionContext(connectionId, frame.input));
452
563
  await previous.promise;
453
- const saved = await options.executor.results.recover(frame.input, frame.requestId);
454
- if (saved)
455
- for (const event of saved.events)
456
- send({ type: "runtime.event", requestId: frame.requestId, event });
564
+ await replaySaved(frame.requestId, frame.input, context, frame.requestId);
457
565
  return;
458
566
  }
459
567
  const requestContext = async (executionSignal) => {
@@ -601,7 +709,6 @@ async function connect(options) {
601
709
  shutdown();
602
710
  try {
603
711
  await closed;
604
- await Promise.allSettled([...active.values()].map((entry) => entry.promise));
605
712
  }
606
713
  finally {
607
714
  clearTimeout(readyTimer);
@@ -118,6 +118,8 @@ export declare class NativeIngest {
118
118
  private readonly reads;
119
119
  private readonly timers;
120
120
  private readonly failures;
121
+ /** File versions that hit a size limit; they are not read again until the file changes. */
122
+ private readonly oversized;
121
123
  private readonly aliveAt;
122
124
  private readonly runningPaths;
123
125
  /** Files a live Cohub execution is writing in a format that cannot mark its Turn. */
@@ -210,14 +212,19 @@ export declare class NativeIngest {
210
212
  };
211
213
  transcriptFor(sessionId: string, harness: Harness, headTurnId: string | null): TranscriptState | null;
212
214
  scan(): Promise<void>;
213
- plan(filter?: ImportFilter, signal?: AbortSignal): Promise<Array<NativeCandidate & {
215
+ /** `resume` is automatic resumption: it skips file versions that already hit a size limit, which only an explicit import retries. */
216
+ plan(filter?: ImportFilter, signal?: AbortSignal, options?: {
217
+ resume?: boolean;
218
+ }): Promise<Array<NativeCandidate & {
214
219
  nativeSessionId?: string;
215
220
  }>>;
216
221
  /**
217
222
  * Start or resume a backfill. What was imported is tracked, so resuming simply plans again; the
218
223
  * server recognizes any Turn it already has, so an interrupted batch is never duplicated.
219
224
  */
220
- startImport(filter?: ImportFilter, concurrency?: number): Promise<ImportJob>;
225
+ startImport(filter?: ImportFilter, concurrency?: number, options?: {
226
+ resume?: boolean;
227
+ }): Promise<ImportJob>;
221
228
  pauseImport(): ImportJob;
222
229
  watch(signal: AbortSignal): Promise<void>;
223
230
  private watchHarness;
@@ -231,6 +238,7 @@ export declare class NativeIngest {
231
238
  private report;
232
239
  private sync;
233
240
  private read;
241
+ private readVersion;
234
242
  private crossesBoundary;
235
243
  private blank;
236
244
  private harnessFor;
@@ -2,6 +2,7 @@ import { appendFile, mkdir, open, readFile, readdir, rename, stat } from "node:f
2
2
  import { join } from "node:path";
3
3
  import { NATIVE_INGEST_MAX_TURNS, SETTLED_TURN_STATUSES } from "@neta-art/cohub";
4
4
  import { acceptsDirectory, acceptsTranscript, readSessionHeader, readTranscript, transcriptRoots } from "./adapters.js";
5
+ import { SizeLimitError } from "./transcript.js";
5
6
  import { nativeTurnId } from "./identity.js";
6
7
  import { harnessAdapter, transcriptFormat } from "../harnesses/index.js";
7
8
  import { containsNativePath } from "./paths.js";
@@ -27,6 +28,7 @@ const RETRY_MAX_MS = 5 * 60_000;
27
28
  */
28
29
  const INGEST_BATCH_TURNS = Math.min(50, NATIVE_INGEST_MAX_TURNS);
29
30
  const IMPORT_FAILURES_KEPT = 100;
31
+ const fileVersion = (info) => `${info.ino}:${info.size}:${info.mtimeMs}`;
30
32
  /** Headers are read this many at a time; each costs one small read. */
31
33
  const HEADER_CONCURRENCY = 16;
32
34
  /** Whether a session in `cwd` belongs to the directory bound at `root`. */
@@ -105,6 +107,8 @@ export class NativeIngest {
105
107
  reads = new Set();
106
108
  timers = new Map();
107
109
  failures = new Map();
110
+ /** File versions that hit a size limit; they are not read again until the file changes. */
111
+ oversized = new Map();
108
112
  aliveAt = new Map();
109
113
  runningPaths = new Set();
110
114
  /** Files a live Cohub execution is writing in a format that cannot mark its Turn. */
@@ -320,21 +324,23 @@ export class NativeIngest {
320
324
  }
321
325
  this.drainLive();
322
326
  if (this.job.state === "running")
323
- await this.startImport(this.job.filter, this.job.concurrency);
327
+ await this.startImport(this.job.filter, this.job.concurrency, { resume: true });
324
328
  }
325
- async plan(filter = {}, signal) {
329
+ /** `resume` is automatic resumption: it skips file versions that already hit a size limit, which only an explicit import retries. */
330
+ async plan(filter = {}, signal, options = {}) {
326
331
  const harnesses = this.harnesses.filter((harness) => !filter.harnesses?.length || filter.harnesses.includes(harness));
327
332
  const candidates = await discoverTranscripts(this.options.cwd, harnesses, { headers: true, nested: this.nested, signal });
328
333
  // A transcript whose first read failed is still untracked (size -1), so a later import retries it.
329
334
  return candidates.filter((candidate) => (this.states.get(candidate.path)?.size ?? -1) < 0 && !this.live.has(candidate.path)
335
+ && !(options.resume && this.oversized.get(candidate.path) === fileVersion(candidate))
330
336
  && (!filter.nativeSessionId || candidate.nativeSessionId === filter.nativeSessionId));
331
337
  }
332
338
  /**
333
339
  * Start or resume a backfill. What was imported is tracked, so resuming simply plans again; the
334
340
  * server recognizes any Turn it already has, so an interrupted batch is never duplicated.
335
341
  */
336
- async startImport(filter = {}, concurrency = DEFAULT_IMPORT_CONCURRENCY) {
337
- const queue = await this.plan(filter);
342
+ async startImport(filter = {}, concurrency = DEFAULT_IMPORT_CONCURRENCY, options = {}) {
343
+ const queue = await this.plan(filter, undefined, options);
338
344
  const resuming = this.job.state === "running" || this.job.state === "paused";
339
345
  const same = resuming && JSON.stringify(this.job.filter) === JSON.stringify(filter);
340
346
  const base = same ? this.job : { ...this.job, done: 0, turns: 0, bytes: 0, failed: [], startedAt: new Date().toISOString() };
@@ -450,7 +456,7 @@ export class NativeIngest {
450
456
  }
451
457
  retryLater(path, error) {
452
458
  this.report(path, error);
453
- if (this.stopped || !this.transport)
459
+ if (this.stopped || !this.transport || error instanceof SizeLimitError)
454
460
  return;
455
461
  const attempts = (this.failures.get(path) ?? 0) + 1;
456
462
  if (attempts > RETRY_ATTEMPTS) {
@@ -508,6 +514,20 @@ export class NativeIngest {
508
514
  return null; throw error; });
509
515
  if (!info?.isFile())
510
516
  return;
517
+ const version = fileVersion(info);
518
+ if (lane === "live" && this.oversized.get(path) === version)
519
+ return;
520
+ this.oversized.delete(path);
521
+ try {
522
+ await this.readVersion(path, lane, force, info);
523
+ }
524
+ catch (error) {
525
+ if (error instanceof SizeLimitError)
526
+ this.oversized.set(path, version);
527
+ throw error;
528
+ }
529
+ }
530
+ async readVersion(path, lane, force, info) {
511
531
  const previous = this.states.get(path);
512
532
  // Unchanged bytes can still hold a Turn whose end the control plane has only now reported.
513
533
  if (previous && this.unchanged(previous, info) && !(force && previous.runningTurnId))
@@ -41,6 +41,10 @@ export type TranscriptLine = {
41
41
  };
42
42
  type Line = TranscriptLine;
43
43
  export declare const iso: (value: unknown) => string;
44
+ /** A size limit that the same bytes will hit again; retrying before the file changes is wasted work. */
45
+ export declare class SizeLimitError extends Error {
46
+ name: string;
47
+ }
44
48
  export declare const NATIVE_MAX_RECORD_BYTES: number;
45
49
  export declare const NATIVE_MAX_TRANSCRIPT_BYTES: number;
46
50
  export declare const codexSessionsDirectory: () => string;
@@ -19,6 +19,10 @@ export const iso = (value) => {
19
19
  return date.toISOString();
20
20
  };
21
21
  const missing = (error) => error?.code === "ENOENT";
22
+ /** A size limit that the same bytes will hit again; retrying before the file changes is wasted work. */
23
+ export class SizeLimitError extends Error {
24
+ name = "SizeLimitError";
25
+ }
22
26
  export const NATIVE_MAX_RECORD_BYTES = 256 * 1024 * 1024;
23
27
  export const NATIVE_MAX_TRANSCRIPT_BYTES = 1024 * 1024 * 1024;
24
28
  const NATIVE_HEADER_MAX_BYTES = 1024 * 1024;
@@ -203,7 +207,7 @@ export async function ensurePlainCodexRollout(path, cacheDirectory, signal, maxB
203
207
  transform(chunk, _encoding, push) {
204
208
  written += chunk.length;
205
209
  if (written > maxBytes) {
206
- push(new Error("Decompressed rollout exceeds the local limit"));
210
+ push(new SizeLimitError("Decompressed rollout exceeds the local limit"));
207
211
  return;
208
212
  }
209
213
  push(null, chunk);
@@ -231,9 +235,9 @@ export async function readTranscriptLines(stream) {
231
235
  pendingBytes += bytes.length;
232
236
  checksum.update(bytes);
233
237
  if (pendingBytes > NATIVE_MAX_RECORD_BYTES)
234
- throw new Error("Native record exceeds the 256 MiB local parse limit; original retained");
238
+ throw new SizeLimitError("Native record exceeds the 256 MiB local parse limit; original retained");
235
239
  if (offset + pendingBytes > NATIVE_MAX_TRANSCRIPT_BYTES)
236
- throw new Error("Native transcript exceeds the 1 GiB local parse limit; original retained");
240
+ throw new SizeLimitError("Native transcript exceeds the 1 GiB local parse limit; original retained");
237
241
  };
238
242
  for await (const chunk of stream) {
239
243
  let start = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "10.8.0",
3
+ "version": "10.8.1",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -18,7 +18,7 @@
18
18
  "NOTICE"
19
19
  ],
20
20
  "dependencies": {
21
- "@neta-art/cohub": "12.0.0",
21
+ "@neta-art/cohub": "12.1.0",
22
22
  "@neta-art/generation": "^0.1.40",
23
23
  "commander": "^15.0.0",
24
24
  "sharp": "^0.35.4",