@twinsearth/w2m-dsh-plugin 1.0.7 → 1.0.9
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/CHANGELOG.md +83 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/plugin/tools.mjs +83 -10
- package/src/relay/persistence.mjs +104 -0
- package/src/relay/server.mjs +26 -0
- package/src/relay/state.mjs +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,89 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
-
## [1.0.
|
|
5
|
+
## [1.0.9] — 2026-10-17
|
|
6
|
+
|
|
7
|
+
**Discovery: a machine can now ask the relay which blob its fleet published last, instead of a human relaying a
|
|
8
|
+
64-character hash between machines.**
|
|
9
|
+
|
|
10
|
+
### Why the channel needed this before it was usable
|
|
11
|
+
|
|
12
|
+
v1.0.7/1.0.8 gave the fleet a content-addressed channel -- anyone in the fleet can `pull <hash>`. **But a hash is
|
|
13
|
+
only useful to someone who already has it**, and the only place one appeared was `publish`'s output on the machine
|
|
14
|
+
that ran it. That makes the channel work exactly as well as a person copying a string, which is a demo, not a sync.
|
|
15
|
+
|
|
16
|
+
So the relay keeps **one pointer per fleet**: the blob that fleet published most recently.
|
|
17
|
+
|
|
18
|
+
| Piece | Where |
|
|
19
|
+
|---|---|
|
|
20
|
+
| pointer store | `<state>/blobs/latest.json`, shape `{ "<fleet_id>": { hash, bytes, published_at_ms, published_by } }` |
|
|
21
|
+
| advanced by | `putBlob`, together with the ownership record -- a publish that succeeded but did not advance the pointer would leave the fleet advertising a stale document |
|
|
22
|
+
| route | `GET /v1/fleet/blobs/latest`, scoped through the same `scopeBlobFleet` as the blobs themselves |
|
|
23
|
+
| tool | `w2m_memory {"action":"latest"}`, and `{"action":"pull","hash":"latest"}` |
|
|
24
|
+
|
|
25
|
+
**`hash: "latest"` is the ergonomic point**: a joining machine runs one call, and it is the form that survives a
|
|
26
|
+
teammate republishing. A stored hash goes stale; `latest` follows.
|
|
27
|
+
|
|
28
|
+
**Resolving a pointer does not weaken verification.** The re-hash in `pull` is unchanged, because a pointer is
|
|
29
|
+
metadata -- exactly the thing a relay is allowed to be wrong about -- while the hash is what the bytes must match.
|
|
30
|
+
|
|
31
|
+
### A pointer is a hint, not a guarantee
|
|
32
|
+
|
|
33
|
+
Eviction is oldest-first and can take the current document on a busy fleet. So `latest` may name a blob that no
|
|
34
|
+
longer exists, and a pull of it answers `404`. **That is the honest outcome**: keeping a document alive because it
|
|
35
|
+
is pointed at would make eviction depend on a second, unstated rule. The route says so in its own payload.
|
|
36
|
+
|
|
37
|
+
### The defect a test caught, recorded because it is easy to reintroduce
|
|
38
|
+
|
|
39
|
+
The first version of `advanceFleetLatest` skipped `fleetId === null`, reasoning that a fleetless device has no
|
|
40
|
+
fleet to advertise to. **That skipped the most common deployment.** On a relay with no fleet configured,
|
|
41
|
+
`scopeFleetId` returns `{fleetId: null, legacy: true}` -- and `null` there means **one shared scope**, the
|
|
42
|
+
pre-v0.7.7 behaviour that fleet scoping's own comment insists a migration must not change. The pointer was therefore
|
|
43
|
+
never written on an unclaimed relay, and `latest` answered `404` for every machine on the default single-relay
|
|
44
|
+
setup. **The feature worked nowhere it was most likely to be used**, and the test that asserted 200 is the only
|
|
45
|
+
reason this is a comment in the source rather than a bug report.
|
|
46
|
+
|
|
47
|
+
The genuinely fleetless case on a **claimed** relay never reaches that code: `scopeFleetId` refuses it outright, so
|
|
48
|
+
the blob is rejected before a pointer could exist.
|
|
49
|
+
|
|
50
|
+
### Verified
|
|
51
|
+
|
|
52
|
+
Four new tests in `test/relay.test.mjs` run against a real relay: the pointer advances on publish and follows the
|
|
53
|
+
newest one; it is scoped per fleet (B sees `404` while A sees its own); **the pointer survives eviction while the
|
|
54
|
+
pull does not** -- the pointer still names the removed blob, the payload says "hint, not a guarantee", and the read
|
|
55
|
+
answers `404` without resurrecting it; and a second fleet re-publishing identical bytes **cannot claim the pointer**.
|
|
56
|
+
|
|
57
|
+
Full suite **2360 tests, 2346 pass, 0 fail, 14 skip**. Pre-flight **GREEN, 15 of 15 checks**.
|
|
58
|
+
|
|
59
|
+
### NOT verified
|
|
60
|
+
|
|
61
|
+
The `latest` and `pull latest` tool actions **end to end** -- same gap as v1.0.7's publish/pull: they are
|
|
62
|
+
type-correct and lint-clean, but no test executes them, because that needs a profile with a `fleet-memory.md` and a
|
|
63
|
+
live relay.
|
|
64
|
+
|
|
65
|
+
### And still absent
|
|
66
|
+
|
|
67
|
+
Merge (#2). Two machines publishing produce two blobs, and the pointer names whichever published last -- **the
|
|
68
|
+
earlier document is not lost, it is merely no longer `latest`**. That visibility is the input merge needs, and
|
|
69
|
+
nothing yet says who wins when two machines assert different facts.
|
|
70
|
+
|
|
71
|
+
## [1.0.8] — 2026-10-17
|
|
72
|
+
|
|
73
|
+
**The fleet sync channel, both directions: a machine can publish its collective memory to the relay and another
|
|
74
|
+
machine in the same fleet can fetch it back by hash.**
|
|
75
|
+
|
|
76
|
+
> **Why 1.0.7 and 1.0.8 are both published, and what that records.** `pnpm publish 1.0.7` succeeded; a verification
|
|
77
|
+
> read a minute later answered `version not found: 1.0.7`, a retry answered
|
|
78
|
+
> `409 Cannot publish over previously staged version "1.0.7"`, and `npm stage list` reported nothing -- so 1.0.7 was
|
|
79
|
+
> concluded to have failed and 1.0.8 was cut. **The registry was simply lagging: both versions are published, and
|
|
80
|
+
> their contents are identical apart from the version string.** The 409 was the retry racing a publish that was
|
|
81
|
+
> still in flight, not evidence that nothing had been accepted.
|
|
82
|
+
>
|
|
83
|
+
> **The mistake worth keeping is the conclusion, not the retry.** A freshness read that disagrees with a successful
|
|
84
|
+
> write is evidence of lag, not of failure -- and the way to tell them apart is to wait and re-read, which is
|
|
85
|
+
> cheaper than re-cutting a release. 1.0.8 stands as the current version because it is already published and is the
|
|
86
|
+
> one `latest` names; 1.0.7 is deprecated pointing here rather than left to look like a distinct release.
|
|
87
|
+
|
|
6
88
|
|
|
7
89
|
**The fleet sync channel, both directions: a machine can publish its collective memory to the relay and another
|
|
8
90
|
machine in the same fleet can fetch it back by hash.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.9",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
|
|
|
208
208
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
209
209
|
* overrides it at runtime for tests and packagers.
|
|
210
210
|
*/
|
|
211
|
-
const PLUGIN_VERSION = '1.0.
|
|
211
|
+
const PLUGIN_VERSION = '1.0.9';
|
|
212
212
|
|
|
213
213
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
214
214
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -5141,12 +5141,13 @@ export async function apply(ctx, config = {}) {
|
|
|
5141
5141
|
*/
|
|
5142
5142
|
action: {
|
|
5143
5143
|
type: 'string',
|
|
5144
|
-
enum: ['read', 'publish', 'pull'],
|
|
5144
|
+
enum: ['read', 'publish', 'pull', 'latest'],
|
|
5145
5145
|
description:
|
|
5146
5146
|
'read (default) = answer from memory. publish = upload the curated collective memory '
|
|
5147
|
-
+ '(`fleet-memory.md`) to the relay as a content-addressed blob and return its SHA-256.
|
|
5148
|
-
+ '
|
|
5149
|
-
+ '
|
|
5147
|
+
+ '(`fleet-memory.md`) to the relay as a content-addressed blob and return its SHA-256. latest = ask the '
|
|
5148
|
+
+ 'relay which blob this fleet published last (the discovery half of the channel). pull = fetch one blob '
|
|
5149
|
+
+ 'by SHA-256 -- or by "latest" -- and return its text, re-hashing what came back against the name it was '
|
|
5150
|
+
+ 'asked for. None of these modifies the local file.',
|
|
5150
5151
|
default: 'read',
|
|
5151
5152
|
},
|
|
5152
5153
|
hash: {
|
|
@@ -5332,17 +5333,88 @@ export async function apply(ctx, config = {}) {
|
|
|
5332
5333
|
* content is re-hashed here, and a mismatch is a failure rather than a warning -- accepting unverified bytes
|
|
5333
5334
|
* under a name they do not match would make every later reader's check decorative too.
|
|
5334
5335
|
*/
|
|
5336
|
+
/**
|
|
5337
|
+
* v1.0.9: which blob this fleet published last -- **the discovery half of the channel**.
|
|
5338
|
+
*
|
|
5339
|
+
* Without it the channel only works if a human reads a 64-character hash off one machine and types it into
|
|
5340
|
+
* another, which is a demo rather than a sync.
|
|
5341
|
+
*/
|
|
5342
|
+
if (typeof args?.action === 'string' && args.action === 'latest') {
|
|
5343
|
+
const base = requireRabbit(cfg);
|
|
5344
|
+
const token = await resolveToken(cfg);
|
|
5345
|
+
const pointer = await request({
|
|
5346
|
+
rabbitUrl: base,
|
|
5347
|
+
token,
|
|
5348
|
+
pathname: '/v1/fleet/blobs/latest',
|
|
5349
|
+
signal: exec?.signal,
|
|
5350
|
+
});
|
|
5351
|
+
if (pointer.status === 404) {
|
|
5352
|
+
throw new W2MError(
|
|
5353
|
+
'W2M_NOT_FOUND',
|
|
5354
|
+
'W2M_NOT_FOUND: this fleet has published no blob yet',
|
|
5355
|
+
{ hint: 'run `action: "publish"` on a machine in this fleet first' },
|
|
5356
|
+
);
|
|
5357
|
+
}
|
|
5358
|
+
if (pointer.status !== 200) {
|
|
5359
|
+
throw new W2MError(
|
|
5360
|
+
'W2M_RABBIT_REFUSED',
|
|
5361
|
+
`W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/latest answered HTTP ${pointer.status}`,
|
|
5362
|
+
{ http_status: pointer.status, detail: pointer.error ?? null },
|
|
5363
|
+
);
|
|
5364
|
+
}
|
|
5365
|
+
return JSON.stringify({
|
|
5366
|
+
ok: true,
|
|
5367
|
+
hash: pointer.json?.hash ?? null,
|
|
5368
|
+
bytes: pointer.json?.bytes ?? null,
|
|
5369
|
+
published_at: pointer.json?.published_at ?? null,
|
|
5370
|
+
published_by: pointer.json?.published_by ?? null,
|
|
5371
|
+
next: 'w2m_memory {"action":"pull","hash":"latest"} fetches it and verifies it against this hash',
|
|
5372
|
+
});
|
|
5373
|
+
}
|
|
5374
|
+
|
|
5335
5375
|
if (typeof args?.action === 'string' && args.action === 'pull') {
|
|
5336
|
-
|
|
5376
|
+
let wanted = typeof args?.hash === 'string' ? args.hash.trim().toLowerCase() : '';
|
|
5377
|
+
const base = requireRabbit(cfg);
|
|
5378
|
+
const token = await resolveToken(cfg);
|
|
5379
|
+
/**
|
|
5380
|
+
* `"latest"` resolves the fleet pointer first.
|
|
5381
|
+
*
|
|
5382
|
+
* **The pointer is trusted only to name a hash; the bytes are still verified against that hash.** Resolving
|
|
5383
|
+
* must not weaken the re-hash below, or the pointer becomes the thing trusted instead of the content -- and
|
|
5384
|
+
* a pointer is metadata, which is exactly what a relay is allowed to be wrong about.
|
|
5385
|
+
*/
|
|
5386
|
+
let viaLatest = false;
|
|
5387
|
+
if (wanted === 'latest') {
|
|
5388
|
+
viaLatest = true;
|
|
5389
|
+
const pointer = await request({
|
|
5390
|
+
rabbitUrl: base,
|
|
5391
|
+
token,
|
|
5392
|
+
pathname: '/v1/fleet/blobs/latest',
|
|
5393
|
+
signal: exec?.signal,
|
|
5394
|
+
});
|
|
5395
|
+
if (pointer.status === 404) {
|
|
5396
|
+
throw new W2MError(
|
|
5397
|
+
'W2M_NOT_FOUND',
|
|
5398
|
+
'W2M_NOT_FOUND: this fleet has published no blob to follow',
|
|
5399
|
+
{ hint: 'publish from a machine in this fleet first, or pass an explicit hash' },
|
|
5400
|
+
);
|
|
5401
|
+
}
|
|
5402
|
+
if (pointer.status !== 200) {
|
|
5403
|
+
throw new W2MError(
|
|
5404
|
+
'W2M_RABBIT_REFUSED',
|
|
5405
|
+
`W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/latest answered HTTP ${pointer.status}`,
|
|
5406
|
+
{ http_status: pointer.status, detail: pointer.error ?? null },
|
|
5407
|
+
);
|
|
5408
|
+
}
|
|
5409
|
+
wanted = String(pointer.json?.hash ?? '');
|
|
5410
|
+
}
|
|
5337
5411
|
if (!/^[0-9a-f]{64}$/.test(wanted)) {
|
|
5338
5412
|
throw new W2MError(
|
|
5339
5413
|
'W2M_CONFIG',
|
|
5340
|
-
'W2M_CONFIG: `action: "pull"` needs `hash
|
|
5341
|
-
{ hint: 'the hash is what `action: "publish"` returned
|
|
5414
|
+
'W2M_CONFIG: `action: "pull"` needs `hash` -- 64 lowercase hex characters, or the word "latest"',
|
|
5415
|
+
{ hint: 'the hash is what `action: "publish"` returned, and "latest" follows this fleet\'s own pointer' },
|
|
5342
5416
|
);
|
|
5343
5417
|
}
|
|
5344
|
-
const base = requireRabbit(cfg);
|
|
5345
|
-
const token = await resolveToken(cfg);
|
|
5346
5418
|
const result = await request({
|
|
5347
5419
|
rabbitUrl: base,
|
|
5348
5420
|
token,
|
|
@@ -5385,6 +5457,7 @@ export async function apply(ctx, config = {}) {
|
|
|
5385
5457
|
ok: true,
|
|
5386
5458
|
pulled: true,
|
|
5387
5459
|
hash: wanted,
|
|
5460
|
+
via_latest: viaLatest,
|
|
5388
5461
|
bytes: Buffer.byteLength(body, 'utf8'),
|
|
5389
5462
|
verified: true,
|
|
5390
5463
|
content: body,
|
|
@@ -39,6 +39,28 @@ export const MAX_BLOBS = 256;
|
|
|
39
39
|
*/
|
|
40
40
|
export const BLOB_INDEX_FILE = 'index.json';
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* v1.0.9: **which blob is each fleet's current document** -- the discovery half of the channel.
|
|
44
|
+
*
|
|
45
|
+
* ## Why a hash alone is not enough
|
|
46
|
+
*
|
|
47
|
+
* v1.0.7/1.0.8 gave the fleet a content-addressed channel: anyone in the fleet can `pull <hash>`. **A hash is only
|
|
48
|
+
* useful to someone who already has it**, and the only way to get one was to read `publish`'s output on another
|
|
49
|
+
* machine. That makes the channel work exactly as well as a human relaying a 64-character string.
|
|
50
|
+
*
|
|
51
|
+
* So the relay keeps one pointer per fleet: four fields of metadata it was already entitled to hold, because it
|
|
52
|
+
* already holds `fleet_id` for every blob.
|
|
53
|
+
*
|
|
54
|
+
* ## A pointer is a hint, not a guarantee
|
|
55
|
+
*
|
|
56
|
+
* Eviction is oldest-first and can take the current document on a busy fleet. `latest` may therefore name a blob
|
|
57
|
+
* that no longer exists, and a pull of it answers `404`. **That is the honest outcome**: keeping a document alive
|
|
58
|
+
* because it is pointed at would make eviction depend on a second, unstated rule.
|
|
59
|
+
*
|
|
60
|
+
* Shape: `{ "<fleet_id>": { hash, bytes, published_at_ms, published_by } }`.
|
|
61
|
+
*/
|
|
62
|
+
export const BLOB_LATEST_FILE = 'latest.json';
|
|
63
|
+
|
|
42
64
|
/** Ledger entry types understood by `RabbitState#restore`. */
|
|
43
65
|
export const LEDGER_TYPES = Object.freeze([
|
|
44
66
|
'task.created',
|
|
@@ -335,6 +357,12 @@ export class Persistence {
|
|
|
335
357
|
if (entry === undefined) {
|
|
336
358
|
index[hash] = { fleet_id: fleetId, added_at_ms: this._now() };
|
|
337
359
|
this.writeBlobIndex(index);
|
|
360
|
+
this.advanceFleetLatest(fleetId, hash, bytes.length, options.publishedBy);
|
|
361
|
+
} else if (entry.fleet_id === fleetId) {
|
|
362
|
+
// Re-publishing the same bytes is a statement that this is the current document, so the pointer advances
|
|
363
|
+
// even though the path did not change. Only for the **owner**: pointing another fleet's pointer at a blob it
|
|
364
|
+
// cannot read would advertise a document that answers 404 to the very fleet being told about it.
|
|
365
|
+
this.advanceFleetLatest(fleetId, hash, bytes.length, options.publishedBy);
|
|
338
366
|
}
|
|
339
367
|
return { ok: true, hash, bytes: bytes.length, evicted: [] };
|
|
340
368
|
}
|
|
@@ -352,6 +380,7 @@ export class Persistence {
|
|
|
352
380
|
}
|
|
353
381
|
index[hash] = { fleet_id: fleetId, added_at_ms: this._now() };
|
|
354
382
|
this.writeBlobIndex(index);
|
|
383
|
+
this.advanceFleetLatest(fleetId, hash, bytes.length, options.publishedBy);
|
|
355
384
|
return { ok: true, hash, bytes: bytes.length, evicted };
|
|
356
385
|
}
|
|
357
386
|
|
|
@@ -486,6 +515,81 @@ export class Persistence {
|
|
|
486
515
|
}
|
|
487
516
|
}
|
|
488
517
|
|
|
518
|
+
/** Read the fleet -> latest-blob pointer map. Missing or corrupt reads as empty, never throws. */
|
|
519
|
+
readBlobLatest() {
|
|
520
|
+
if (!this.enabled) return {};
|
|
521
|
+
try {
|
|
522
|
+
const raw = fs.readFileSync(path.join(this.dir, BLOB_DIR, BLOB_LATEST_FILE), 'utf8');
|
|
523
|
+
const parsed = JSON.parse(raw);
|
|
524
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
525
|
+
} catch {
|
|
526
|
+
return {};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Atomically replace the pointer map. Best-effort, like the ownership index. */
|
|
531
|
+
writeBlobLatest(latest) {
|
|
532
|
+
if (!this.enabled) return false;
|
|
533
|
+
const file = path.join(this.dir, BLOB_DIR, BLOB_LATEST_FILE);
|
|
534
|
+
try {
|
|
535
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
536
|
+
const tmp = `${file}.tmp-${process.pid}-${this._now()}`;
|
|
537
|
+
fs.writeFileSync(tmp, `${JSON.stringify(latest)}\n`, 'utf8');
|
|
538
|
+
fs.renameSync(tmp, file);
|
|
539
|
+
return true;
|
|
540
|
+
} catch (error) {
|
|
541
|
+
this.warn(BLOB_DIR, `blob latest-pointer write failed: ${error?.message ?? String(error)}`);
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Point a fleet at the blob it just published.
|
|
548
|
+
*
|
|
549
|
+
* ## `null` is a real scope, not a missing value
|
|
550
|
+
*
|
|
551
|
+
* The first version of this method skipped `fleetId === null`, reasoning that a fleetless device has no fleet to
|
|
552
|
+
* advertise to. **That was wrong for the most common deployment.** On a relay with no fleet configured,
|
|
553
|
+
* `scopeFleetId` returns `{fleetId: null, legacy: true}` -- and `null` there means **one shared scope**, which
|
|
554
|
+
* is exactly the pre-v0.7.7 behaviour that fleet scoping's own comment insists a migration must not change:
|
|
555
|
+
* *"a migration must not change behaviour for devices that were never asked to change."*
|
|
556
|
+
*
|
|
557
|
+
* Skipping it meant the pointer was never written on an unclaimed relay, so `latest` answered 404 for every
|
|
558
|
+
* machine on the default single-relay setup -- the feature worked nowhere it was most likely to be used. A test
|
|
559
|
+
* caught it, which is the only reason this is a comment instead of a bug report.
|
|
560
|
+
*
|
|
561
|
+
* The genuinely fleetless case on a **claimed** relay never reaches here: `scopeFleetId` refuses it outright
|
|
562
|
+
* (`REQUESTER_HAS_NO_FLEET`), so the blob is rejected before any pointer could be written.
|
|
563
|
+
*
|
|
564
|
+
* @param {string|null} fleetId `null` means the relay-wide legacy scope
|
|
565
|
+
* @param {string} hash
|
|
566
|
+
* @param {number} bytes
|
|
567
|
+
* @param {string|null} [publishedBy]
|
|
568
|
+
*/
|
|
569
|
+
advanceFleetLatest(fleetId, hash, bytes, publishedBy = null) {
|
|
570
|
+
if (!this.enabled || fleetId === undefined) return false;
|
|
571
|
+
const latest = this.readBlobLatest();
|
|
572
|
+
latest[fleetId] = {
|
|
573
|
+
hash,
|
|
574
|
+
bytes,
|
|
575
|
+
published_at_ms: this._now(),
|
|
576
|
+
published_by: typeof publishedBy === 'string' && publishedBy !== '' ? publishedBy : null,
|
|
577
|
+
};
|
|
578
|
+
return this.writeBlobLatest(latest);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The blob `fleetId` last published, or `null`.
|
|
583
|
+
*
|
|
584
|
+
* **A returned pointer is a hint.** It may name a blob that eviction has since removed, and the caller is expected
|
|
585
|
+
* to read it and answer not-found rather than being told a comfortable lie here.
|
|
586
|
+
*/
|
|
587
|
+
fleetLatest(fleetId) {
|
|
588
|
+
if (!this.enabled || fleetId === undefined) return null;
|
|
589
|
+
const entry = this.readBlobLatest()[fleetId];
|
|
590
|
+
return entry !== undefined && typeof entry.hash === 'string' ? entry : null;
|
|
591
|
+
}
|
|
592
|
+
|
|
489
593
|
/** `<state>/blobs/<aa>/<sha256>`, or `null` when persistence is off or the hash is not a sha256. */
|
|
490
594
|
blobFile(hash) {
|
|
491
595
|
if (!this.enabled) return null;
|
package/src/relay/server.mjs
CHANGED
|
@@ -787,6 +787,32 @@ export class RelayServer {
|
|
|
787
787
|
* fleet is **not readable by another**, and the read refusal is a 404 rather than a 403: a distinguishable
|
|
788
788
|
* "not yours" tells a prober that the hash exists here.
|
|
789
789
|
*/
|
|
790
|
+
/**
|
|
791
|
+
* v1.0.9: **which blob this fleet published last** -- the discovery half of the channel.
|
|
792
|
+
*
|
|
793
|
+
* Checked **before** the `<sha256>` matcher deliberately. The matcher only accepts 64 hex characters, so
|
|
794
|
+
* `latest` cannot collide with it today; **the ordering is asserted anyway**, because "these two patterns cannot
|
|
795
|
+
* conflict" is exactly the reasoning that produces a shadowed route the day one of them is relaxed.
|
|
796
|
+
*
|
|
797
|
+
* A fleet that has never published gets a 404 rather than an empty object: "there is no current document" and
|
|
798
|
+
* "the current document is nothing" are different statements, and only one of them is true here.
|
|
799
|
+
*/
|
|
800
|
+
if (method === 'GET' && path === '/v1/fleet/blobs/latest') {
|
|
801
|
+
const latest = this.state.latestFleetBlob(device);
|
|
802
|
+
if (latest === null) {
|
|
803
|
+
throw new ProtocolError('NOT_FOUND', 'this fleet has published no blob', {});
|
|
804
|
+
}
|
|
805
|
+
return sendJson(res, 200, {
|
|
806
|
+
protocol_version: PROTOCOL_VERSION,
|
|
807
|
+
hash: latest.hash,
|
|
808
|
+
bytes: latest.bytes,
|
|
809
|
+
published_at: new Date(latest.published_at_ms).toISOString(),
|
|
810
|
+
published_by: latest.published_by ?? null,
|
|
811
|
+
// Stated in the payload, not only in the docs: a pointer can outlive its blob under oldest-first eviction.
|
|
812
|
+
hint: 'a hint, not a guarantee -- a pull may answer 404 if the blob has since been evicted',
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
790
816
|
const blobMatch = /^\/v1\/fleet\/blobs\/([0-9a-f]{64})$/.exec(path);
|
|
791
817
|
if (blobMatch !== null) {
|
|
792
818
|
const hash = blobMatch[1];
|
package/src/relay/state.mjs
CHANGED
|
@@ -579,6 +579,22 @@ export class RabbitState {
|
|
|
579
579
|
return this._persist.getBlob(hash, { fleetId: scope.fleetId });
|
|
580
580
|
}
|
|
581
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Which blob this device's fleet published most recently, or `null`.
|
|
584
|
+
*
|
|
585
|
+
* **This is the discovery half of the channel** (v1.0.9): without it a hash is only useful to whoever read
|
|
586
|
+
* `publish`'s output, so a "sync" would need a human relaying a 64-character string between machines.
|
|
587
|
+
*
|
|
588
|
+
* Scoped by the same rule as the blobs themselves -- a fleetless device on a claimed relay gets `null`, not a
|
|
589
|
+
* shared pointer it could never read.
|
|
590
|
+
*/
|
|
591
|
+
latestFleetBlob(device) {
|
|
592
|
+
if (!this._persist) return null;
|
|
593
|
+
const scope = this.scopeBlobFleet(device);
|
|
594
|
+
if (scope.ok !== true) return null;
|
|
595
|
+
return this._persist.fleetLatest(scope.fleetId);
|
|
596
|
+
}
|
|
597
|
+
|
|
582
598
|
/** Snapshot the device table; failures must never break the request path. */
|
|
583
599
|
_saveDevices() {
|
|
584
600
|
if (this._restoring || !this._persist) return false;
|