@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 +8 -8
- package/src/provider.ts +13 -1
- package/src/sync.test.ts +172 -2
- package/src/sync.ts +136 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-fly-sprite",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
29
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
30
|
-
"@frockbot/computer-host-runtime": "0.3.
|
|
31
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
32
|
-
"@frockbot/plugin-computer": "0.3.
|
|
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.
|
|
37
|
-
"@frockbot/workspace-store": "0.3.
|
|
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 {
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|