@frockbot/plugin-fly-sprite 0.3.26 → 0.3.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-fly-sprite",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,16 +25,16 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@cordisjs/plugin-webui": "0.8.2",
28
- "@frockbot/computer-core": "0.3.26",
29
- "@frockbot/computer-host-protocol": "0.3.26",
30
- "@frockbot/computer-host-runtime": "0.3.26",
31
- "@frockbot/kernel-contracts": "0.3.26",
32
- "@frockbot/plugin-computer": "0.3.26",
28
+ "@frockbot/computer-core": "0.3.28",
29
+ "@frockbot/computer-host-protocol": "0.3.28",
30
+ "@frockbot/computer-host-runtime": "0.3.28",
31
+ "@frockbot/kernel-contracts": "0.3.28",
32
+ "@frockbot/plugin-computer": "0.3.28",
33
33
  "cordis": "4.0.0-rc.8"
34
34
  },
35
35
  "devDependencies": {
36
- "@frockbot/plugin-testkit": "0.3.26",
37
- "@frockbot/workspace-store": "0.3.26",
36
+ "@frockbot/plugin-testkit": "0.3.28",
37
+ "@frockbot/workspace-store": "0.3.28",
38
38
  "@types/bun": "1.4.0",
39
39
  "@types/node": "26.2.0",
40
40
  "typescript": "^7.0.2"
package/src/provider.ts CHANGED
@@ -304,6 +304,7 @@ function summarize(report: WorkspaceSyncReportV1): ComputerSyncSummaryV1 {
304
304
  const failed = report.failures[0];
305
305
  const ignored = total((root) => root.ignored);
306
306
  const omitted = total((root) => root.omitted);
307
+ const required = report.roots.flatMap((root) => root.required);
307
308
  const detail: string[] = [];
308
309
  // Reproducible trees (node_modules, dist, …) are excluded by design; the
309
310
  // count is kept on the summary for the Session log, but it is not a reason
@@ -315,6 +316,16 @@ function summarize(report: WorkspaceSyncReportV1): ComputerSyncSummaryV1 {
315
316
  `Omitted ${omitted} manifest ${omitted === 1 ? "entry" : "entries"} at the sync safety limit.`,
316
317
  );
317
318
  }
319
+ // A path the caller declared required, that the Computer holds and the store
320
+ // still does not, is the one outcome a caller must never read as success.
321
+ const stranded = required.filter(
322
+ (entry) => entry.contentHash !== undefined && !entry.durable,
323
+ );
324
+ if (stranded[0]) {
325
+ detail.push(
326
+ `${stranded.length} required ${stranded.length === 1 ? "file is" : "files are"} on the Computer but not in the Workspace, starting at "${stranded[0].path}".`,
327
+ );
328
+ }
318
329
  if (failed) {
319
330
  detail.push(
320
331
  `${report.failures.length} sync ${report.failures.length === 1 ? "operation" : "operations"} failed${failed.path ? ` at "${failed.path}"` : ""}: ${failed.status}: ${failed.reason}.`,
@@ -328,7 +339,7 @@ function summarize(report: WorkspaceSyncReportV1): ComputerSyncSummaryV1 {
328
339
  report.failures.length > 0 &&
329
340
  report.roots.every((root) => root.failures.length > 0)
330
341
  ? "unavailable"
331
- : report.failures.length > 0 || omitted > 0
342
+ : report.failures.length > 0 || omitted > 0 || stranded.length > 0
332
343
  ? "degraded"
333
344
  : "ok",
334
345
  detail: detail.join(" ").slice(0, 512),
@@ -343,6 +354,7 @@ function summarize(report: WorkspaceSyncReportV1): ComputerSyncSummaryV1 {
343
354
  omitted,
344
355
  conflicts: report.conflicts.length,
345
356
  failures: report.failures.length,
357
+ ...(required.length > 0 ? { required } : {}),
346
358
  };
347
359
  return summary;
348
360
  }
package/src/sync.test.ts CHANGED
@@ -11,7 +11,10 @@ import {
11
11
  type WorkspaceRootV1,
12
12
  type WorkspaceWriterV1,
13
13
  } from "@frockbot/kernel-contracts";
14
- import { createObjectWorkspaceFilesV1 } from "@frockbot/workspace-store";
14
+ import {
15
+ createObjectWorkspaceFilesV1,
16
+ workspaceObjectKeyV1,
17
+ } from "@frockbot/workspace-store";
15
18
  import {
16
19
  createInMemoryObjectBucketV1,
17
20
  createInMemoryWorkspaceGenerationsV1,
@@ -1351,7 +1354,7 @@ describe("the durable-root sync on the Computer handle", () => {
1351
1354
  { botId: BOT },
1352
1355
  { providerId: "fly-sprite", generation: 1 },
1353
1356
  );
1354
- return { sprite, store, generations, effects, provider, open };
1357
+ return { sprite, store, bucket, generations, effects, provider, open };
1355
1358
  }
1356
1359
 
1357
1360
  test("pulls the store's durable roots onto the Computer before the Bot's first use", async () => {
@@ -1488,6 +1491,173 @@ describe("the durable-root sync on the Computer handle", () => {
1488
1491
  expect(refused.status).toBe("refused");
1489
1492
  });
1490
1493
 
1494
+ // Production, 2026-09-04, run 76b4f8d9: `applet build` wrote `dist/`, the
1495
+ // publish sync answered `pushed=0 removed=0 failures=0`, and the publish then
1496
+ // read `dist/manifest.json` as `not-found` and told the Bot to build again.
1497
+ // The reconcile had trusted the sidecar: hash matched, entry "clean",
1498
+ // nothing to do — while the store held no object for it at all. A required
1499
+ // path is the one thing the caller has asserted about, so the Computer's
1500
+ // copy of it decides, not its sidecar.
1501
+ test("pushes a required file the store is missing even when its sidecar says clean", async () => {
1502
+ const { sprite, store, open } = providerHarness([
1503
+ APPLET_SOURCE_PACKAGE_ROOT,
1504
+ ]);
1505
+ const handle = await open();
1506
+ const appletId = "pub-user-1.0123456789abcdef0123456789abcdef";
1507
+ const path = `${appletId}/dist/manifest.json`;
1508
+ const manifest = '{"tools":[{"name":"add"},{"name":"list"}]}';
1509
+ sprite.shellWrite(MOUNTS.appletSource, path, manifest);
1510
+ // One good publish: the store takes the bytes and the Computer records the
1511
+ // sidecar that attributes them.
1512
+ await handle.sync!.reconcileRoot!(appletSourceRoot, "publish", {
1513
+ requiredPaths: [path],
1514
+ });
1515
+ const stat = await store.stat({ root: appletSourceRoot, path });
1516
+ expect(stat.status).toBe("ok");
1517
+ if (stat.status !== "ok") return;
1518
+ // The object goes; the sidecar and the built file do not.
1519
+ await store.delete({
1520
+ path: { root: appletSourceRoot, path },
1521
+ writer: USER_WRITER,
1522
+ expectedGenerationId: stat.entry.generation.generationId,
1523
+ });
1524
+ expect(
1525
+ sprite.files.has(`${MOUNTS.appletSource}/.frockbot-generations/${path}`),
1526
+ ).toBe(true);
1527
+
1528
+ const summary = await handle.sync!.reconcileRoot!(
1529
+ appletSourceRoot,
1530
+ "publish",
1531
+ { requiredPaths: [path] },
1532
+ );
1533
+
1534
+ expect(summary).toMatchObject({
1535
+ status: "ok",
1536
+ pushed: 1,
1537
+ removed: 0,
1538
+ failures: 0,
1539
+ });
1540
+ const read = await store.read({ root: appletSourceRoot, path });
1541
+ expect(read.status).toBe("ok");
1542
+ if (read.status === "ok")
1543
+ expect(decoder.decode(read.file.bytes)).toBe(manifest);
1544
+ expect(summary.required).toEqual([
1545
+ { path, contentHash: sha256(encoder.encode(manifest)), durable: true },
1546
+ ]);
1547
+ });
1548
+
1549
+ // The same drift, the other way round: the file the publish needs must never
1550
+ // be deleted from the Computer because the store is missing it. Before this,
1551
+ // the reconcile read the absence as a store-side delete and removed the build
1552
+ // output — the publish's own artifact — on the way past.
1553
+ test("never removes a required file from the Computer because the store lacks it", async () => {
1554
+ const { sprite, store, generations, open } = providerHarness([
1555
+ APPLET_SOURCE_PACKAGE_ROOT,
1556
+ ]);
1557
+ const handle = await open();
1558
+ const appletId = "pub-user-1.0123456789abcdef0123456789abcdef";
1559
+ const path = `${appletId}/dist/server.js`;
1560
+ sprite.shellWrite(MOUNTS.appletSource, path, "export class A{}");
1561
+ await handle.sync!.reconcileRoot!(appletSourceRoot, "publish", {
1562
+ requiredPaths: [path],
1563
+ });
1564
+ const stat = await store.stat({ root: appletSourceRoot, path });
1565
+ if (stat.status !== "ok") throw new Error("the first push did not land");
1566
+ // A recorded store-side delete: the ledger holds a tombstone, so this is a
1567
+ // removal by every rule the sync knows — and a required path still wins.
1568
+ await store.delete({
1569
+ path: { root: appletSourceRoot, path },
1570
+ writer: USER_WRITER,
1571
+ expectedGenerationId: stat.entry.generation.generationId,
1572
+ });
1573
+ expect((await generations.current(appletSourceRoot, path))?.deleted).toBe(
1574
+ true,
1575
+ );
1576
+
1577
+ await handle.sync!.reconcileRoot!(appletSourceRoot, "publish", {
1578
+ requiredPaths: [path],
1579
+ });
1580
+
1581
+ expect(sprite.text(`${MOUNTS.appletSource}/${path}`)).toBe(
1582
+ "export class A{}",
1583
+ );
1584
+ expect((await store.read({ root: appletSourceRoot, path })).status).toBe(
1585
+ "ok",
1586
+ );
1587
+ });
1588
+
1589
+ // A store that lost the object but kept the generation the sidecar names is
1590
+ // repaired rather than re-written: the bytes already match, so a second
1591
+ // generation for identical content would be noise in the ledger.
1592
+ test("repairs a required file's sidecar without writing a second generation", async () => {
1593
+ const { sprite, store, open } = providerHarness([
1594
+ APPLET_SOURCE_PACKAGE_ROOT,
1595
+ ]);
1596
+ const handle = await open();
1597
+ const appletId = "pub-user-1.0123456789abcdef0123456789abcdef";
1598
+ const path = `${appletId}/dist/manifest.json`;
1599
+ sprite.shellWrite(MOUNTS.appletSource, path, '{"tools":[]}');
1600
+ await handle.sync!.reconcileRoot!(appletSourceRoot, "publish", {
1601
+ requiredPaths: [path],
1602
+ });
1603
+ // `rm -rf dist && applet build` reproducing byte-identical output, with the
1604
+ // sidecar lost: the store already holds these bytes.
1605
+ sprite.files.delete(`${MOUNTS.appletSource}/.frockbot-generations/${path}`);
1606
+
1607
+ const summary = await handle.sync!.reconcileRoot!(
1608
+ appletSourceRoot,
1609
+ "publish",
1610
+ { requiredPaths: [path] },
1611
+ );
1612
+
1613
+ expect(summary).toMatchObject({ status: "ok", pushed: 0, conflicts: 0 });
1614
+ expect(summary.required).toMatchObject([{ path, durable: true }]);
1615
+ expect(
1616
+ sprite.files.has(`${MOUNTS.appletSource}/.frockbot-generations/${path}`),
1617
+ ).toBe(true);
1618
+ });
1619
+
1620
+ // A required file the Computer simply does not have is still a truthful
1621
+ // answer, and the caller is owed the fact rather than a silent `ok`.
1622
+ test("reports a required file the Computer does not hold", async () => {
1623
+ const { open } = providerHarness([APPLET_SOURCE_PACKAGE_ROOT]);
1624
+ const handle = await open();
1625
+ const path = "pub-user-1.0123456789abcdef0123456789abcdef/dist/ui.html";
1626
+
1627
+ const summary = await handle.sync!.reconcileRoot!(
1628
+ appletSourceRoot,
1629
+ "publish",
1630
+ { requiredPaths: [path] },
1631
+ );
1632
+
1633
+ expect(summary.status).toBe("ok");
1634
+ expect(summary.required).toEqual([{ path, durable: false }]);
1635
+ });
1636
+
1637
+ // ADR 0013 makes a delete a recorded generation, so an absence the ledger
1638
+ // never recorded is drift and not a removal. The general case: an ordinary
1639
+ // Skill whose object went missing under a sidecar keeps its bytes, and the
1640
+ // sync puts them back rather than deleting the User's file.
1641
+ test("re-pushes rather than removes when the store recorded no removal", async () => {
1642
+ const { sprite, store, bucket, open } = providerHarness();
1643
+ const handle = await open();
1644
+ sprite.shellWrite(MOUNTS.skills, "deploy/SKILL.md", "# deploy");
1645
+ await handle.sync!.reconcile("turn-end");
1646
+ expect(
1647
+ (await store.read({ root: skillsRoot, path: "deploy/SKILL.md" })).status,
1648
+ ).toBe("ok");
1649
+ // The object disappears with no ledger tombstone: not a delete anyone made.
1650
+ await bucket.delete(workspaceObjectKeyV1(skillsRoot, "deploy/SKILL.md"));
1651
+
1652
+ const summary = await handle.sync!.reconcile("turn-end");
1653
+
1654
+ expect(summary).toMatchObject({ removed: 0, failures: 0 });
1655
+ expect(sprite.text(`${MOUNTS.skills}/deploy/SKILL.md`)).toBe("# deploy");
1656
+ expect(
1657
+ (await store.read({ root: skillsRoot, path: "deploy/SKILL.md" })).status,
1658
+ ).toBe("ok");
1659
+ });
1660
+
1491
1661
  // Production, 2026-09-04: `applet build` wrote a 470 KB `dist/ui.html`, and
1492
1662
  // every publish that followed failed, because one `base64` of the file was
1493
1663
  // 627 KB of answer and the storage command may answer 500 KB. Both directions
package/src/sync.ts CHANGED
@@ -309,6 +309,20 @@ export interface WorkspaceSyncFailureV1 extends WorkspaceFailureV1 {
309
309
  path?: string;
310
310
  }
311
311
 
312
+ /**
313
+ * What the run saw for one path its caller declared required.
314
+ *
315
+ * `durable` is a statement about the store and not about the sidecar: it is
316
+ * true only when the store answered with the same content hash the Computer
317
+ * holds, or accepted a push of exactly those bytes during this run.
318
+ */
319
+ export interface WorkspaceSyncRequiredPathV1 {
320
+ path: string;
321
+ /** sha-256 of the bytes on the Computer, absent when it held no such file. */
322
+ contentHash?: string;
323
+ durable: boolean;
324
+ }
325
+
312
326
  export interface WorkspaceSyncRootReportV1 {
313
327
  root: WorkspaceRootV1;
314
328
  /** Store generations materialized on the Computer. */
@@ -329,6 +343,8 @@ export interface WorkspaceSyncRootReportV1 {
329
343
  omitted: number;
330
344
  conflicts: WorkspaceSyncConflictV1[];
331
345
  failures: WorkspaceSyncFailureV1[];
346
+ /** One row per path the caller declared required; empty when it named none. */
347
+ required: WorkspaceSyncRequiredPathV1[];
332
348
  }
333
349
 
334
350
  export interface WorkspaceSyncReportV1 {
@@ -405,6 +421,7 @@ function emptyReport(root: WorkspaceRootV1): WorkspaceSyncRootReportV1 {
405
421
  omitted: 0,
406
422
  conflicts: [],
407
423
  failures: [],
424
+ required: [],
408
425
  };
409
426
  }
410
427
 
@@ -439,6 +456,7 @@ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
439
456
  requiredPaths: readonly string[] = [],
440
457
  ): Promise<WorkspaceSyncRootReportV1> {
441
458
  const report = emptyReport(root);
459
+ const required = new Set(requiredPaths);
442
460
  const scanned = await this.options.computer.scan(root, requiredPaths);
443
461
  if (isFailure(scanned)) {
444
462
  report.failures.push({ ...scanned, root });
@@ -468,8 +486,28 @@ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
468
486
  // Push first: a Computer-side write must reach the store's conditional
469
487
  // write before the pull could overwrite it.
470
488
  for (const entry of scanned.scan.entries) {
471
- if (this.clean(entry)) continue;
472
- const pushed = await this.push(root, entry, report);
489
+ // A required path is authoritative on the Computer. The caller has
490
+ // asserted that these exact bytes must be readable from the store when
491
+ // this returns, so the sidecar's opinion is not enough: ask the store
492
+ // what it holds, and push whenever it does not hold these bytes. A
493
+ // sidecar that survived its file (sidecars live outside `dist/`, so
494
+ // `rm -rf dist` never touches them) or an object the store lost after
495
+ // the push that recorded one otherwise leaves a "clean" entry nobody
496
+ // ever carries — the publish failure of 2026-09-04.
497
+ const drift = required.has(entry.path)
498
+ ? await this.requiredDrift(root, entry, report)
499
+ : undefined;
500
+ if (drift?.kind === "aligned") {
501
+ settled.add(entry.path);
502
+ continue;
503
+ }
504
+ if (!drift && this.clean(entry)) continue;
505
+ const pushed = await this.push(
506
+ root,
507
+ entry,
508
+ report,
509
+ drift ? { expected: drift.expected } : undefined,
510
+ );
473
511
  if (pushed === "pushed") settled.add(entry.path);
474
512
  else if (pushed === "conflict") conflicted.add(entry.path);
475
513
  else held.add(entry.path);
@@ -515,16 +553,91 @@ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
515
553
  if (stored.generations.has(entry.path)) continue;
516
554
  if (held.has(entry.path) || settled.has(entry.path)) continue;
517
555
  if (conflicted.has(entry.path)) continue;
556
+ // A required path is never turned into a local removal. The caller named
557
+ // it because the Computer's copy is the thing being published; deleting
558
+ // it because the store is missing it destroys the very bytes the caller
559
+ // asked for, and the forced push above has already had its say.
560
+ if (required.has(entry.path)) continue;
518
561
  if (!entry.recorded) continue;
519
562
  // The Computer holds a file the store recorded and no longer holds: a
520
563
  // delete happened there. It becomes a removal here, recorded, never a
521
- // silent overwrite.
522
- const removed = await this.removeLocally(root, entry, report);
564
+ // silent overwrite — *if* the store actually recorded a removal. When
565
+ // the ledger is reachable and holds no tombstone for the path, this is
566
+ // sidecar/store drift rather than a delete, and re-pushing the bytes the
567
+ // Computer still holds is the repair. See ADR 0013: a removal is a
568
+ // recorded generation, so an absence with no record is not one.
569
+ const tombstone = await this.storeTombstone(root, entry);
570
+ if (tombstone === "no-removal-recorded") {
571
+ if (readOnlyOnComputer) continue;
572
+ const pushed = await this.push(root, entry, report, {
573
+ expected: null,
574
+ });
575
+ if (pushed === "pushed") settled.add(entry.path);
576
+ continue;
577
+ }
578
+ const removed = await this.removeLocally(root, entry, report, tombstone);
523
579
  if (removed) report.removedOnComputer.push(entry.path);
524
580
  }
581
+ for (const path of required) {
582
+ const entry = local.get(path);
583
+ report.required.push({
584
+ path,
585
+ ...(entry ? { contentHash: entry.contentHash } : {}),
586
+ // Either this run put those bytes in the store, or the listing already
587
+ // answered with them. Anything else is reported as not durable, which
588
+ // is what makes the caller's own failure honest.
589
+ durable:
590
+ entry !== undefined &&
591
+ (settled.has(path) ||
592
+ stored.generations.get(path)?.contentHash === entry.contentHash),
593
+ });
594
+ }
525
595
  return report;
526
596
  }
527
597
 
598
+ /**
599
+ * What the store holds for one required path, when that differs from the
600
+ * Computer.
601
+ *
602
+ * `undefined` means nothing is provable — the store could not answer — and
603
+ * the sidecar rule stands. `aligned` means the store already holds these
604
+ * exact bytes, and the sidecar (missing or stale) is repaired rather than a
605
+ * second generation written. Otherwise the answer carries the generation the
606
+ * forced push must condition on, so the push cannot lose a race it started.
607
+ */
608
+ private async requiredDrift(
609
+ root: WorkspaceRootV1,
610
+ entry: ComputerSyncEntryV1,
611
+ report: WorkspaceSyncRootReportV1,
612
+ ): Promise<
613
+ { kind: "aligned" } | { kind: "push"; expected: string | null } | undefined
614
+ > {
615
+ const current = await this.options.store.stat({ root, path: entry.path });
616
+ if (current.status === "not-found") {
617
+ return this.clean(entry) ? { kind: "push", expected: null } : undefined;
618
+ }
619
+ if (current.status !== "ok") return undefined;
620
+ if (current.entry.generation.contentHash !== entry.contentHash) {
621
+ return { kind: "push", expected: current.entry.generation.generationId };
622
+ }
623
+ if (entry.recorded?.generationId === current.entry.generation.generationId)
624
+ return { kind: "aligned" };
625
+ // The store holds these bytes under a generation the Computer does not
626
+ // record. Repair the sidecar so the next run agrees, and carry nothing.
627
+ const bytes = await this.options.computer.read(root, entry.path);
628
+ if (isFailure(bytes)) return { kind: "aligned" };
629
+ const sidecar = await this.options.computer.materialize(
630
+ root,
631
+ entry.path,
632
+ bytes.bytes,
633
+ current.entry.generation,
634
+ );
635
+ if (sidecar.status !== "ok") {
636
+ report.failures.push({ ...sidecar, root, path: entry.path });
637
+ }
638
+ return { kind: "aligned" };
639
+ }
640
+
528
641
  /** True when the file's bytes are still the ones its sidecar attributes. */
529
642
  private clean(entry: ComputerSyncEntryV1): boolean {
530
643
  return (
@@ -579,13 +692,21 @@ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
579
692
  root: WorkspaceRootV1,
580
693
  entry: ComputerSyncEntryV1,
581
694
  report: WorkspaceSyncRootReportV1,
695
+ /**
696
+ * The generation to condition on, when the caller has read the store and
697
+ * knows better than the sidecar. Absent means the sidecar decides, which
698
+ * is the ordinary case.
699
+ */
700
+ override?: { expected: string | null },
582
701
  ): Promise<"pushed" | "conflict" | "held"> {
583
702
  const bytes = await this.options.computer.read(root, entry.path);
584
703
  if (isFailure(bytes)) {
585
704
  report.failures.push({ ...bytes, root, path: entry.path });
586
705
  return "held";
587
706
  }
588
- const expected = entry.recorded?.generationId ?? null;
707
+ const expected = override
708
+ ? override.expected
709
+ : (entry.recorded?.generationId ?? null);
589
710
  const effect: WorkspaceSyncEffectV1 = {
590
711
  effectId: workspaceSyncEffectIdV1(
591
712
  root,
@@ -814,8 +935,8 @@ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
814
935
  root: WorkspaceRootV1,
815
936
  entry: ComputerSyncEntryV1,
816
937
  report: WorkspaceSyncRootReportV1,
938
+ tombstone: WorkspaceGenerationV1,
817
939
  ): Promise<boolean> {
818
- const tombstone = await this.storeTombstone(root, entry);
819
940
  const removed = await this.options.computer.remove(
820
941
  root,
821
942
  entry.path,
@@ -836,15 +957,23 @@ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
836
957
  * The tombstone generation to record on the Computer for a store-side
837
958
  * delete. The ledger holds the writer when the Durable Object is reachable;
838
959
  * otherwise the removal is recorded with no writer, which is the truth.
960
+ *
961
+ * `"no-removal-recorded"` is the third answer, and the one that keeps the
962
+ * removal rule honest: the ledger answered, and it holds no tombstone for
963
+ * this path. ADR 0013 makes a delete a recorded generation, so an absence
964
+ * with no record is drift between the sidecar and the store rather than a
965
+ * delete, and the caller re-pushes instead of destroying the file. An
966
+ * unreachable ledger says nothing either way, so the removal stands.
839
967
  */
840
968
  private async storeTombstone(
841
969
  root: WorkspaceRootV1,
842
970
  entry: ComputerSyncEntryV1,
843
- ): Promise<WorkspaceGenerationV1> {
971
+ ): Promise<WorkspaceGenerationV1 | "no-removal-recorded"> {
844
972
  if (this.options.generations) {
845
973
  try {
846
974
  const record = await this.options.generations.current(root, entry.path);
847
975
  if (record?.deleted) return record.generation;
976
+ return "no-removal-recorded";
848
977
  } catch {
849
978
  // The ledger is unreachable; record the removal without a writer.
850
979
  }